For the complete documentation index, see llms.txt. This page is also available as Markdown.

75296 sc low permanent freezing of dispute game bonds in aggregateverifier on dual proof chains proof threshold 2 due to unreachable claimcredit fallback

Submitted on Apr 28th 2026 at 11:25:00 UTC by @Oxodus for Audit Comp | Base Azul

  • Report ID: #75296

  • 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

    • Manipulating dispute game bond mechanics to economically grief proposers or prevent legitimate proposals from being submitted

Description

Brief/Intro

On any chain configured with PROOF_THRESHOLD >= 2 (the documented dual-proof end-state), an AggregateVerifier dispute game that reaches the state proofCount == 1 && block.timestamp > expectedResolution becomes permanently stuck. resolve() reverts forever, claimCredit()'s 14-day rescue path is unreachable, and verifyProposalProof() cannot accept a late proof. The proposer's INIT_BOND is permanently locked in DelayedWETH with no available recovery path. The trigger requires no invalid proof; a single 7-day ZK-prover unavailability is sufficient.

Vulnerability Details

The bug is the interaction of three pieces of contracts/src/multiproof/AggregateVerifier.sol. First, resolve() hard-gates on the proof threshold at line 458, reverting NotEnoughProofs whenever fewer proofs have been accepted than PROOF_THRESHOLD:

if (proofCount < PROOF_THRESHOLD) revert NotEnoughProofs();

Second, _getDelay() at line 815 maps proofCount == 1 to SLOW_FINALIZATION_DELAY (7 days) rather than type(uint64).max; only proofCount == 0 returns the sentinel:

Third, claimCredit() at lines 613-617 conditions its 14-day rescue branch on expectedResolution == type(uint64).max, otherwise routing to a branch that requires resolvedAt != 0:

When proofCount == 1 and PROOF_THRESHOLD == 2, expectedResolution is set to now + 7 days (not uint64.max), so the 14-day fallback is never taken and claimCredit reverts forever. verifyProposalProof() additionally reverts GameOver after expectedResolution (line 423), so a late second proof cannot rescue the game. The author's intent contradicts the implementation: the comment in challenge() at lines 526-530 reads "If the ZK is nullified, we allow the remaining TEE proof to resolve", but the threshold gate prevents exactly that.

The trapped state is reachable via two independent paths. The first is liveness-only with no attacker and no invalid proof: the proposer initializes a game with a valid TEE proof (proofCount = 1, expectedResolution = now + 7 days); the ZK prover fails to submit within the SLOW window for any benign reason (outage, slow proving, witness-fetcher bug, single-blob censorship); after 7 days the game is permanently stuck. The second is a post-nullification scenario: TEE init, then a ZK challenge brings proofCount to 2, then anyone calls nullify(zkProof, idx, originalRoot) with a ZK proof supporting the original root — _proofRefutedUpdate(ZK) drops proofCount back to 1 and additionally flips Verifier.nullified on the shared ZK_VERIFIER contract. Because nullified is a contract-level flag rather than per-game, the same nullification permanently bricks every future dual proof game on the chain that uses that verifier.

Impact Details

  • Per-game loss: the full INIT_BOND (production value set by DisputeGameFactory.setInitBond) is permanently trapped in DelayedWETH. closeGame() requires resolvedAt != 0, and DelayedWETH.unlock is only callable from claimCredit(). No governance recovery path exists currently exists.

  • Liveness collapse: every game whose ZK prover is unavailable for 7+ days reaches the trap state. No adversary required.

  • Systemic brick after a single nullification since Verifier.nullified is contract level, one successful nullification on any clone permanently bricks every future dual proof game on the chain until the verifier is replaced.

  • Recovery would require a AggregateVerifier implementation upgrade and migration of DisputeGameFactory.gameImpls[621]. In-flight bonds in the old implementation are not recovered by the upgrade.

References

  • contracts/src/multiproof/AggregateVerifier.sol

    • resolve() threshold gate — line 458

    • claimCredit() fallback gate — lines 606-634

    • _getDelay() — lines 815-822

    • _increaseExpectedResolution() — lines 800-813

    • verifyProposalProof() GameOver revert — line 423

    • Author-intent comment in challenge() — lines 526-535

  • contracts/src/multiproof/Verifier.solnullified is contract-level, not per-game

Proof of Concept

Create a file named BondLockOnDualProof.t.sol and paste the following test

Run the command

The test successfully runs with the following output

Was this helpful?