Tally can bind a ruling no juror voted for, slashing the whole panel and permanently banning round-one jurors
On deadlock the tally returns a hard-coded 50/50 split, and on a split majority it returns the mean of the split votes, neither of which any juror may have voted for. Jurors are then slashed for missing a ruling the aggregation invented, and round-one jurors can be permanently banned.
Description
determineRuling can return a ruling that no juror voted for. Every downstream scoring path then treats that ruling as the standard jurors are measured against, and the two comparators used to measure them disagree with each other.
Two paths produce an unsupported ruling. On deadlock the function returns a hardcoded 50/50 split, and where the split option carries the majority it returns the unweighted arithmetic mean of the revealed split values, which need not lie near any individual vote:
uint256 revealed = forBuyer + forSeller + forSplit;uint256 threshold = (revealed / 2) + 1;...if (forSplit >= threshold) {return (Resolution.Split, uint16(splitBpsSum / forSplit), false);}return (Resolution.Split, 5000, true); // tie
The comparators then diverge. Juror-level matching is tolerant, admitting any split within splitMatchToleranceBps (default 500):
uint256 lo = rulingBps > tol ? rulingBps - tol : 0;uint256 hi = rulingBps + tol;return splitBps >= lo && splitBps <= hi;
Round-level matching, which decides whether an appeal upheld the original ruling, is exact:
if (a == Resolution.Split) return aBps == bBps;
wasTied is computed and emitted at ArbitrationPool.sol#L801, then discarded — nothing downstream distinguishes a deadlock from a decided ruling.
Vulnerable Scenario: The following steps illustrate the issue:
- A three-juror panel is seated with the default
revealQuorumof 2. - Juror A reveals
RefundBuyer, juror B revealsReleaseSeller, juror C does not reveal. tallycounts two reveals, meets quorum, and callsdetermineRuling.forBuyerandforSellerare each 1 against a threshold of 2, so the tie branch returns(Split, 5000).applySlashAndRewardmeasures each juror against that ruling.voteMatchesRulingreturns false for A and B because the ruling isSplitand neither votedSplit, and false for C because a missing vote never matches.winnerCountis 0, soslashShareis 0 and the entire slashed total falls toslashDustand is added tofeePool. All three jurors are slashed, and none is paid.executeapplies the ruling and transfers half the disputed amount to each party, an allocation no juror proposed.
The same shape reaches the appeal path by a second route. Round-one rules Split at 5000. On appeal five jurors reveal [5000, 5000, 5000, 5000, 5005], giving a mean of 5001. Every appeal vote is inside the tolerance so no appeal juror is slashed, but rulingsMatch(Split, 5001, Split, 5000) is false, so the appeal is recorded as an overturn: every round-one juror who matched the old ruling within the protocol's own tolerance is slashed under cappedSlashFromBps and has appealOverturnCount incremented. If that appeal round also ties, payAppealJurorsFromDeposit finds no winner and moves the appellant's entire deposit into feePool rather than returning it.
Impact
Jurors are slashed for failing to match a ruling the aggregation rule invented, and in the tie case the whole panel is slashed while nobody is paid. No attacker and no privilege is required — a single missed reveal on a three-juror panel is sufficient, and ordinary dispersion among split votes is sufficient on appeal.
The overturn penalty is permanent. appealOverturnCount is written in exactly two places, both increments, and has no clearing writer anywhere in the contract. Eligibility divides it by casesResolved, which stops growing the moment the juror becomes ineligible, so the ratio can only rise. A juror moving from 1 overturn in 10 resolved cases to 2 in 11 reaches 18% against the default overturnRateLimit of 15 and is excluded from every future panel. The only available lever is setGovernanceThresholds, which lifts the limit for everyone at once. This is the distinguishing asymmetry from abstentionCount, which the owner can clear per juror through resetAbstentionCounts.
Because split rulings are compared by exact equality, an appeal of a split ruling will almost never be recorded as upheld, so the penalty applies systematically rather than exceptionally.
Recommendation
Make the bound ruling one a juror actually proposed, and measure jurors against a single standard.
Replace the unweighted mean with a rule that returns a value some juror voted for — a median of the revealed splits, or selection among the proposed values — and assert that every binding ruling has at least one juror matching it under voteMatchesRuling. Where none does, the round has not reached a decision and should be treated as a quorum failure rather than a ruling, which also removes the case where the entire panel is slashed and the fee pool absorbs the proceeds.
Pass splitMatchToleranceBps into rulingsMatch so an appeal counts as upholding the original when the two split values are within the same tolerance already applied to jurors, and consume wasTied rather than discarding it: a deadlocked appeal has not established that the round-one panel was wrong and should trigger neither retroactive slashing nor an overturn count.
Add a per-juror correction for appealOverturnCount mirroring resetAbstentionCounts. Without it the exclusions already recorded under the exact comparator stay in place after the comparator is fixed.
Resolution
Fixed. A split ruling now binds to the median of the revealed splits — a value a juror actually voted for — so every binding ruling has at least one matching juror. A true tie returns a no-decision sentinel and re-queues for a fresh panel instead of binding a 50/50 nobody proposed.
Affected files
contracts/lib/ArbitrationPoolTallyLib.sol#L52-L61contracts/lib/ArbitrationPoolTallyLib.sol#L64-L87contracts/lib/ArbitrationPoolTallyLib.sol#L89-L98contracts/lib/ArbitrationPoolTallyLib.sol#L145-L194contracts/lib/ArbitrationPoolTallyLib.sol#L223-L255contracts/lib/ArbitrationPoolTallyLib.sol#L312-L316contracts/ArbitrationPool.sol#L801contracts/lib/ArbitrationPoolSelectLib.sol#L171-L173