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-0012·gas-bound

Juror selection scans the whole registry inside the VRF callback

Fixedescrowarbitrationdispute-resolution
TL;DR

Juror selection scans the whole registry twice inside the VRF callback, so gas grows with registry size. Past roughly a hundred jurors every callback reverts, and arbitration becomes unavailable protocol-wide.

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

Description

selectJurors iterates the entire juror registry twice — once to count eligible entries, once to pack them — inside VRF fulfilment. Each iteration runs _isEligibleForCase, reading the juror struct plus four mappings. Callback gas therefore grows with registry length, not with the fixed panel size of 3 or 5.

Measured against a deferred coordinator, with the callback metered in isolation and pool storage cold — as it is in a real VRF callback transaction — the cost scales linearly with registry size:

Registry entriesCallback gasShare of the 2,500,000 limit
501,452,25958%
1002,328,82593%
1503,266,860exceeds
2004,190,728exceeds

The marginal cost is roughly 18,600 gas per registry entry on about 467,000 of fixed overhead, which puts the crossing point near 109 entries. At 100 entries the callback already consumes 93% of the maximum the coordinator will supply, so the margin disappears well before the limit is formally breached.

vrfCallbackGasLimit is 2,500,000, which is also the maximum Chainlink VRF v2.5 accepts on Polygon, so raising it is not available as a mitigation.

Registry growth is permissionless: stake() admits anyone meeting a tier minimum, and the stake is refundable after cooldown. An attacker can push past the threshold with temporarily-locked, recoverable capital.

03Section · Impact

Impact

Once the registry crosses the threshold, every VRF callback reverts before seating a panel, and retryStuckCase fails identically. Arbitration becomes unavailable protocol-wide; under DD-1 every disputed escrow's principal is locked behind unavailable resolution. No owner configuration restores service. Recovery requires enough jurors to unstake, or redeployment.

The threshold sits inside the protocol's own success case — ~106 Community jurors is 2.65M cNGN of total stake, a small pool by design intent.

Reaching the threshold costs an attacker roughly 109 × 25,000 cNGN ≈ 2.73M cNGN of Community stake, locked only until the unstake cooldown elapses and then fully recoverable. Organic growth reaches it too: the documented target is a pool of 40+ jurors, and the threshold sits under three times that.

04Section · Recommendation

Recommendation

Replace full-registry scans with structures whose callback work is independent of registry size: per-tier eligibility buckets with O(1) sampling, or an eligible-set snapshot taken at openCase (outside the callback) and consumed at fulfilment. Add a gas-bound invariant test at the maximum supported registry size, asserted against the production coordinator's real constraint, not a mock.

05Section · Resolution

Resolution

Fixed. The callback is now flat at 68,655 gas from 10 to 200 registry entries (previously 1.45M at 50 and 2.33M at 100). The registry scan moved to seatPendingPanel, a separate transaction bounded by the block gas limit at roughly 24,183 gas per entry on ~509,000 fixed — moving the ceiling from about 109 jurors to about 1,219, and making a short seating revert recoverably instead of burning the fulfilment.

The split did introduce a new issue: see F-2026-0013.

06Section · Affected files

Affected files

  • lib/ArbitrationPoolSelectLib.sol#L33-L96
  • ArbitrationPool.sol#L221, #L596-L646
Status
Fixed
F-2026-0012