Juror selection scans the whole registry inside the VRF callback
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.
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 entries | Callback gas | Share of the 2,500,000 limit |
|---|---|---|
| 50 | 1,452,259 | 58% |
| 100 | 2,328,825 | 93% |
| 150 | 3,266,860 | exceeds |
| 200 | 4,190,728 | exceeds |
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.
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.
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.
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.
Affected files
lib/ArbitrationPoolSelectLib.sol#L33-L96ArbitrationPool.sol#L221, #L596-L646