Relayed ballot signatures carry no round or nonce binding
Relayed ballot signatures carry no round, nonce, pool address or expiry, and a quorum failure re-queues the same case ID. A ballot signed for the failed round can be replayed into the next one, casting a vote the juror never authorised.
Description
The gasless envelopes omit the pool address, the panel-seating epoch, a nonce and an expiry:
keccak256(abi.encodePacked("COMMIT_VOTE", caseId, commitHash, block.chainid));keccak256(abi.encodePacked("REVEAL_VOTE", caseId, uint8(vote), splitBps, salt, block.chainid));
The commit preimage checked at reveal is less bound still — keccak256(vote, splitBps, salt, juror)
and keccak256(vote, salt, juror). Neither carries a caseId, a round, nor a nonce.
The round-zero quorum-failure branch clears the consumed ballot slots (#L759-L761) and
re-queues the same caseId (#L787), so a genuine signature from the failed panel reconstructs
to a byte-identical hash and is accepted again if that juror is re-seated. Re-draw is permitted:
the branch also clears wasOnPriorPanel[caseId][j], and wasOnPriorPanel is only ever set for
appealRound == 0, so appeal jurors are never marked at all.
The signature check cannot detect this — the signature is genuine, from the same juror, over an
identical hash. Once replayed, the juror cannot correct the ballot: AlreadyCommitted and
AlreadyRevealed then block them.
Impact
The party disfavoured by the replayed ballot loses escrowed cNGN under a ruling the juror never authorised for that round — up to the entire disputed balance. If the replayed ballot dissents from the resulting ruling, the impersonated juror additionally takes the capped stake slash for a vote they did not cast. The actor is the configured relayer: a semi-trusted role authorised to forward ballots, not to choose their context.
Recommendation
Introduce a persistent monotonic ballot epoch per caseId, incremented on every panel seating
including quorum-failure re-queues and appeal retries. It must survive delete cases[caseId]
(#L784), so it cannot live in the Case struct. Bind pool address, caseId, epoch, juror,
action, the complete ballot payload, a nonce and an expiry into both signatures via EIP-712 typed
data carrying chainId and verifyingContract. Bind the same epoch and caseId into the commit
preimage, not only the outer signature — otherwise the commit/reveal pair still replays as a
unit.
Do not implement this with Escrow.signatureNonce. That counter is episode-scoped and
_enterDispute is seller-reachable, so reusing it would invalidate every outstanding buyer
authorisation.
Resolution
Fixed. Relayed ballots are EIP-712 typed and bind the pool domain, caseId, ballot epoch, juror, payload, a split commit/reveal nonce and an expiry. A round-zero signature replayed after a quorum-failure re-queue is now rejected, and the epoch advances with every seating.
Affected files
ArbitrationPool.sol#L678-L684, #L691-L699, #L759-L761, #L787, #L1171, #L1176