Early rate$2,400 of senior audit time for $500. Early members keep the rate as it climbs.$2,400 of senior audit time for $500See how →
F-2026-0010·logic-error

Pool cases are not bound to the dispute episode they adjudicate

Fixedescrowarbitrationdispute-resolution
TL;DR

A case is bound to a milestone only when it is opened, but execution settles whichever milestone is disputed at execution time. A stale ruling can settle a later milestone's dispute that it never heard, transferring that milestone's full principal.

Severity
HIGH
Impact
HIGH
Likelihood
MEDIUM
Method
MManual review
CAT.
Complexity
MEDIUM
Exploitability
MEDIUM
02Section · Description

Description

_caseIdFor binds a case to keccak256(escrow, escrow.disputedMilestoneIndex()) at open time. That binding is never re-checked, and the consequence is that a ruling can settle a dispute it never heard.

execute(caseId) takes a caller-supplied caseId and gates only on the escrow's current generic status. Escrow._resolveDisputeFunds reads disputedMilestoneIndex at resolve time (#L703-L704), not at case-open time. So a ruling adjudicated for milestone N settles whichever milestone is disputed when execute is called:

  1. Milestone 0 is disputed; its case tallies RefundBuyer and reaches Tallied.
  2. The parties mutually settle milestone 0. The escrow returns to Active; the pool case is untouched and stays Tallied.
  3. Milestone 1 is commenced, delivered correctly, then disputed.
  4. Past the old case's appealDeadline, anyone calls execute with milestone 0's caseId. NotTallied, AppealWindowOpen and status() == Disputed all pass.
  5. The stale RefundBuyer transfers milestone 1's full principal to the buyer.

The comment at #L968-L971 shows the settled case was considered — the re-disputed case was missed. The mirror case (stale ReleaseSeller) deprives the buyer identically.

A seller who notices the stale case can defuse it by calling execute while the escrow is Active, which consumes the case harmlessly. That defence fails against a contract buyer: raiseDispute for the later milestone and execute for the stale case can be issued atomically in one transaction, leaving no window in which to pre-empt it.

03Section · Impact

Impact

A party favoured by a superseded ruling takes the entire principal of a later milestone from a counterparty who would have prevailed in that milestone's own arbitration. The transfer is final and bypasses adjudication of the live dispute.

04Section · Recommendation

Recommendation

Store the dispute episode index in Case at open time and require it to equal escrow.disputedMilestoneIndex() before forwarding any ruling in execute. Add an authenticated settlement→pool cancellation path that releases assignments and reserved fees and deletes any pending VRF record for the escrow. Keep the existing resolved-escrow branch so mutual settlements still release juror seats without applying a ruling.

05Section · Resolution

Resolution

Fixed. The case captures the dispute episode at openCase and re-checks it at execution, so a ruling from a superseded episode is rejected.

06Section · Affected files

Affected files

  • ArbitrationPool.sol#L957-L993 (execute), #L1220-L1222 (_caseIdFor)
  • Escrow.sol#L702-L735, #L770-L786
Status
Fixed
F-2026-0010