74495 sc low missing proof threshold check in aggregateverifier resolution scheduling leads to permanent freezing of dispute game bonds
Submitted on Apr 22nd 2026 at 23:46:52 UTC by @Web3Angel for Audit Comp | Base Azul
Report ID: #74495
Report Type: Smart Contract
Report severity: Low
Target: https://github.com/base/contracts/tree/v8.1.0/src/multiproof
Impacts:
Permanent freezing of funds in the bridge or in dispute game bonds with no available recovery path
Description
Brief/Intro
AggregateVerifier.sol can put a game into a permanently unresolvable state when PROOF_THRESHOLD is configured to 2 and only one proof is present. The game receives a finite expectedResolution timestamp even though it does not have enough proofs to resolve. Once that timestamp passes, AggregateVerifier::resolve() reverts because the proof threshold is unmet, while AggregateVerifier::claimCredit() also reverts because the game has not resolved and the 14-day fallback is disabled. In production, this can permanently freeze dispute game bonds with no available on-chain recovery path.
Vulnerability Details
AggregateVerifier supports both PROOF_THRESHOLD = 1 and PROOF_THRESHOLD = 2:
if (proofThreshold != 1 && proofThreshold != 2) revert InvalidProofThreshold();However, its resolution clock logic is based on the absolute number of proofs, not on whether the configured threshold has been met.
During AggregateVerifier::initializeWithInitData(), the game starts by setting expectedResolution to the sentinel value:
It then verifies the initializer proof and calls AggregateVerifier::_proofVerifiedUpdate():
AggregateVerifier::_proofVerifiedUpdate() increments proofCount and calls AggregateVerifier::_decreaseExpectedResolution():
The issue is in AggregateVerifier::_getDelay(). When proofCount == 1, it returns SLOW_FINALIZATION_DELAY even if PROOF_THRESHOLD == 2:
As a result, a one-proof game under a two-proof threshold receives a finite expectedResolution:
After the slow finalization delay passes, the game appears to be over because AggregateVerifier::gameOver() returns true:
But it still cannot resolve as AggregateVerifier::resolve() requires the configured proof threshold to be met:
For PROOF_THRESHOLD = 2, a game with only one proof always reverts with NotEnoughProofs.
The bond recovery fallback in AggregateVerifier::claimCredit() is also unavailable. It only applies when expectedResolution == type(uint64).max:
Given that the one-proof game has a finite expectedResolution and never successfully resolves, resolvedAt remains zero forever. Therefore:
AggregateVerifier::resolve()reverts withNotEnoughProofs.AggregateVerifier::claimCredit()reverts withGameNotResolved.The 14-day fallback cannot be reached.
The dispute game bond remains locked permanently.
The root cause is that the finalization delay is computed from proofCount alone rather than from whether proofCount >= PROOF_THRESHOLD.
Impact Details
Selected impact:
Permanent freezing of funds in the bridge or in dispute game bonds with no available recovery path
The affected funds are the dispute game initialization bond deposited into DelayedWETH during AggregateVerifier::initializeWithInitData():
If PROOF_THRESHOLD = 2, a game initialized with one accepted proof can become permanently unable to return its bond if the second proof does not arrive before the finite deadline. This can happen without privileged access and without compromising any key. It is sufficient for normal protocol operation to create a game with one proof while the second proof path is delayed, unavailable, or intentionally not submitted.
Once the game reaches this state, the creator cannot recover the bond through the normal resolution path or through the 14-day fallback. The loss is bounded per affected game by the configured initialization bond, but the issue can affect every one-proof game created under a two-proof threshold. If many games are created while one proof system is unavailable or delayed, the locked bond amount can accumulate across multiple games.
This is not direct theft, and it does not by itself forge state roots or drain the bridge. The severity comes from permanent fund freezing with no on-chain recovery path.
References
AggregateVerifier::initializeWithInitData()sets the sentinel and then callsAggregateVerifier::_proofVerifiedUpdate():src/multiproof/AggregateVerifier.solAggregateVerifier::_proofVerifiedUpdate(),AggregateVerifier::_decreaseExpectedResolution(), andAggregateVerifier::_getDelay()set a finite deadline for one-proof games:src/multiproof/AggregateVerifier.solAggregateVerifier::resolve()requiresproofCount >= PROOF_THRESHOLD:src/multiproof/AggregateVerifier.solAggregateVerifier::claimCredit()only permits unresolved fallback whenexpectedResolution == type(uint64).max:src/multiproof/AggregateVerifier.solStandalone PoC:
test/OneProofDeadStateStandalone.t.solProtocol-level PoC:
test/protocol/AggregateVerifierFindingsProtocol.t.sol
Proof of Concept
The PoC demonstrates the vulnerable state transition: one accepted proof under PROOF_THRESHOLD = 2 gives the game a finite deadline, but after that deadline AggregateVerifier::resolve() still reverts with NotEnoughProofs and AggregateVerifier::claimCredit() still reverts with GameNotResolved.
Two PoC variants are provided:
Standalone scoped PoC
This is the primary reproduction for the platform-provided scope, which is limited to src/multiproof. The in-scope package "https://github.com/base/c...ree/v8.1.0/src/multiproof" does not include the broader Base repository tests BaseTest, or the protocol deployment helpers needed to instantiate the full dispute-game stack. For that reason, this PoC deploys a minimal harness that copies only the affected AggregateVerifier state transitions needed for this finding: initialization, proof counting, expectedResolution updates, resolve(), and claimCredit(). This keeps the reproduction self-contained within the submitted scope while still demonstrating the exact dead state.
Standalone PoC
The standalone test defines a minimal AggregateVerifierOneProofHarness that copies the relevant logic from:
AggregateVerifier::initializeWithInitData()AggregateVerifier::_proofVerifiedUpdate()AggregateVerifier::_decreaseExpectedResolution()AggregateVerifier::_getDelay()AggregateVerifier::resolve()AggregateVerifier::claimCredit()
The test then:
Deploys the harness with
PROOF_THRESHOLD = 2.Initializes it with one proof and a
1 etherbond.Confirms
proofCount == 1,expectedResolutionis finite, andresolvedAt == 0.Warps past 7 days and confirms
resolve()reverts withNotEnoughProofs.Warps past 14 days and confirms
claimCredit()reverts withGameNotResolved.
File: test/OneProofDeadStateStandalone.t.sol
Create a test folder and copy the following Solidity test into test/OneProofDeadStateStandalone.t.sol:
Run
Run it with a minimal Foundry config that compiles only this PoC test:
The PoC only needs forge-std; it does not import the broader protocol test suite. The temporary poc-foundry/foundry.toml prevents Foundry from compiling unrelated scoped-repo files with missing dependencies.
If the reviewer is running inside a clean Foundry checkout whose default config does not compile unrelated missing dependencies, they can alternatively copy the Solidity code above into test/OneProofDeadStateStandalone.t.sol and run:
Expected console output:
Protocol-level PoC
The protocol-level PoC below is provided only as optional integration evidence. It uses the broader Base repository test harness, including test/BaseTest.t.sol, to deploy the real AggregateVerifier. That broader repository is different from the competition scope: the in-scope package is only src/multiproof, which does not include BaseTest or the rest of the protocol test suite. Therefore, reviewers using only the scoped repository should run the standalone PoC above. Reviewers with access to the broader repository can additionally run this protocol-level test.
This test repeats the standalone sequence against a real AggregateVerifier deployment: create a one-proof game with PROOF_THRESHOLD = 2, confirm its finite expectedResolution, then show that resolve() and claimCredit() both remain unavailable.
The protocol-level test:
Deploys an
AggregateVerifierimplementation withPROOF_THRESHOLD = 2.Creates a game through
DisputeGameFactory::createWithInitData()using one ZK proof.Confirms
proofCount == 1andexpectedResolution == block.timestamp + 7 days.Warps past 7 days and confirms
AggregateVerifier::resolve()reverts withNotEnoughProofs.Warps past 14 days total and confirms
AggregateVerifier::claimCredit()reverts withGameNotResolved.
File: test/protocol/AggregateVerifierFindingsProtocol.t.sol
Create test/protocol/AggregateVerifierFindingsProtocol.t.sol and copy the code below:
Run:
Expected console output:
Recommended Mitigation
The game should not receive a finite finalization deadline until it has enough proofs to resolve under the configured threshold.
One possible fix is to make AggregateVerifier::_getDelay() threshold-aware:
With this change, a one-proof game under PROOF_THRESHOLD = 2 keeps expectedResolution = type(uint64).max until the second proof arrives. If the second proof never arrives, the existing 14-day fallback in AggregateVerifier::claimCredit() remains available.
Alternatively, AggregateVerifier::claimCredit() could explicitly allow bond recovery for games that are past a safety timeout and have proofCount < PROOF_THRESHOLD, while ensuring such games cannot update the anchor state.
Was this helpful?