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

74385 sc low bond permanently locked when proof threshold 2 and the complementary verifier is globally nullified mid game

Submitted on Apr 22nd 2026 at 06:40:24 UTC by @humbleservant for Audit Comp | Base Azul

  • Report ID: #74385

  • 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's constructor admits PROOF_THRESHOLD of either 1 or 2. On a clone deployed with threshold 2, once exactly one of the two proofs (TEE or ZK) has been accepted, if the complementary verifier is globally nullified via Verifier.nullify() before the second proof lands, the clone cannot resolve and cannot refund. The bond posted at init sits in DELAYED_WETH owned by an unreachable game address with no path back to the proposer. When threshold 2 ships to production, every in-flight game that is in the "one proof accepted, one still pending" state at the moment of a soundness-triggered nullify() has its bond permanently frozen with no available recovery path.

Vulnerability Details

Verifier.nullified is a single boolean on the verifier contract. It is not per-game. Any proper, respected sibling dispute game in the anchor registry is authorized to call nullify(). The designed purpose is to respond to an SP1 soundness bug or an enclave image recall. The side effect is that every AggregateVerifier clone that shares that verifier has its subsequent verify() blocked by the notNullified modifier.

// Verifier.sol

bool public nullified;                                        // line 14

modifier notNullified() { if (nullified) revert Nullified(); _; }   // lines 27-30

function nullify() external override {                        // lines 39-47
    if (
        !ANCHOR_STATE_REGISTRY.isGameProper(IDisputeGame(msg.sender))
            || !ANCHOR_STATE_REGISTRY.isGameRespected(IDisputeGame(msg.sender))
    ) revert NotProperGame();
    nullified = true;
    emit VerifierNullified(IDisputeGame(msg.sender));
}

On a threshold 2 clone, the constructor gate accepts the value:

When the proposer has already landed one proof and the other verifier is now nullified, three conditions combine to lock the bond:

First, resolve() reverts forever:

proofCount cannot reach 2 because the second verifyProposalProof reverts inside the nullified verifier. So resolve() is permanently wedged.

Second, claimCredit()'s 14-day creator refund fallback is gated on a state that the game cannot return to:

The active branch is the one at line 614 because expectedResolution != type(uint64).max. The only way expectedResolution returns to type(uint64).max is _decreaseExpectedResolution() observing proofCount == 0:

After the first proof is accepted, proofCount == 1, _decreaseExpectedResolution writes block.timestamp + SLOW_FINALIZATION_DELAY into expectedResolution, and no subsequent call can push proofCount back down to 0. So the 14-day fallback at line 616 is structurally unreachable for this game.

Third, no external function on the clone rescues the bond. Every external path was enumerated:

There is no owner, no guardian, no pause, and no rescueBond. AnchorStateRegistry.isGameBlacklisted and isGameRetired only govern whether child games reference this clone as a parent; they do not release the bond.

Self-nullify via AggregateVerifier.nullify() is not a recovery either. It globally nullifies the same-type verifier across every Azul game and requires a same-type counter-proof that a correct enclave will refuse to produce.

Impact Details

Every deploy config shipped in v8.1.0 sets multiproofProofThreshold = 1:

Other chain configs (mainnet.json, internal-devnet.json, sepolia-devnet-0.json) do not define a multiproof block. The bug cannot manifest on any current live deployment. It is latent until Base ships an implementation with proofThreshold = 2, which is the only reason the constructor admits that value.

Once threshold 2 is deployed, each wedged clone locks its full posted bond inside DELAYED_WETH with no reachable recovery. Total value at risk equals (number of concurrent threshold 2 games in the one-proof-accepted state at the nullify moment) multiplied by (bond per game). After a soundness disclosure, the expected population is every in-flight game that had already collected one proof but not both. With Base's observed game cadence that is on the order of dozens of games at any one time; with Optimism fault-dispute bonds historically at roughly 0.08 ETH to low single-digit ETH per game, the frozen value per soundness event lands in the low-single-digits to low-tens of ETH per chain.

Value is locked, not stolen. No attacker is required, no attacker profit. The finding fits impact category #6 Permanent freezing of funds in the bridge or in dispute game bonds with no available recovery path, because (a) the bond is a dispute game bond, (b) the freeze is permanent with no timeout or recovery function, and (c) every external function of the clone was shown unable to release the bond.

References

  • src/multiproof/AggregateVerifier.sol at https://github.com/base/contracts/blob/v8.1.0/src/multiproof/AggregateVerifier.sol

  • src/multiproof/Verifier.sol at https://github.com/base/contracts/blob/v8.1.0/src/multiproof/Verifier.sol

  • src/multiproof/zk/ZKVerifier.sol at https://github.com/base/contracts/blob/v8.1.0/src/multiproof/zk/ZKVerifier.sol (inherits notNullified on verify())

  • src/multiproof/tee/TEEVerifier.sol at https://github.com/base/contracts/blob/v8.1.0/src/multiproof/tee/TEEVerifier.sol (inherits notNullified on verify())

  • deploy-config/sepolia.json and deploy-config/hardhat.json at https://github.com/base/contracts/tree/v8.1.0/deploy-config

  • Scope page: https://immunefi.com/audit-competition/audit-comp-base-azul/scope/

Proof of Concept

Drop-in Foundry test that reuses the repo's own harness pattern (same topology as test/multiproof/BaseTest.t.sol, only PROOF_THRESHOLD is changed to 2). Save as test/multiproof/StuckBondThreshold2.t.sol and run with forge test --match-path "test/multiproof/StuckBondThreshold2.t.sol" -vv. It compiles with solc 0.8.15 and passes.

Run output:

The trace the test encodes:

  1. Proposer of game A posts bond and a valid ZK proof. proofCount = 1, expectedResolution = block.timestamp + 7 days.

  2. A sibling game B is a proper, respected dispute game in the anchor registry. It submits two conflicting TEE proofs via nullify(), which calls TEE_VERIFIER.nullify(). Verifier(teeVerifier).nullified = true globally.

  3. Game A tries to land the TEE proof. Reverts Nullified() inside TEEVerifier.verify.

  4. Seven days elapse. resolve() reverts NotEnoughProofs() at line 458.

  5. claimCredit() reverts GameNotResolved() at line 614. The 14-day fallback at line 616 is not taken because expectedResolution != type(uint64).max.

  6. Arbitrary time elapses (365 days, then 10 years). claimCredit() still reverts GameNotResolved(). Time does not help.

  7. The bond stays with the clone in DELAYED_WETH permanently.

The same trace works with the proof types swapped (ZK accepted first on game A, then ZK verifier globally nullified by game B).

Treat "the only missing proof type's verifier is globally nullified" as an escape condition and route the proposer through the 14-day creator refund that already exists.

Was this helpful?