Pool cases are not bound to the dispute episode they adjudicate
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.
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:
- Milestone 0 is disputed; its case tallies
RefundBuyerand reachesTallied. - The parties mutually settle milestone 0. The escrow returns to
Active; the pool case is untouched and staysTallied. - Milestone 1 is commenced, delivered correctly, then disputed.
- Past the old case's
appealDeadline, anyone callsexecutewith milestone 0's caseId.NotTallied,AppealWindowOpenandstatus() == Disputedall pass. - The stale
RefundBuyertransfers 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.
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.
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.
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.
Affected files
ArbitrationPool.sol#L957-L993 (execute), #L1220-L1222(_caseIdFor)Escrow.sol#L702-L735, #L770-L786