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

74351 sc low bond permanently locked after zk verifier nullification in proof threshold 2 game

Submitted on Apr 21st 2026 at 22:40:33 UTC by @M1S00 for Audit Comp | Base Azul

  • Report ID: #74351

  • 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

In AggregateVerifier.sol, when a game is configured with PROOF_THRESHOLD = 2 (requiring both a TEE proof and a ZK proof before resolution), a soundness-alert nullification of the ZK verifier can leave the game in a permanently unresolvable state. Once the ZK verifier is nullified via nullify(), the ZK verifier's nullified flag is set to true for the entire shared verifier contract, preventing any future ZK proof from being accepted. At the same time, proofCount drops back to 1, which is below the threshold of 2, so resolve() is blocked. The only intended escape path in claimCredit() - a 14-day unconditional timeout - is gated behind expectedResolution == type(uint64).max, but after nullification expectedResolution is set to a concrete future timestamp (now + SLOW_FINALIZATION_DELAY), meaning the 14-day branch is never reached. The result is that the honest TEE proposer's full bond is locked in DelayedWETH forever with no recovery path in the protocol.

Vulnerable File and Code

File: src/multiproof/AggregateVerifier.sol Function: claimCredit() - Lines 603–619

// src/multiproof/AggregateVerifier.sol

function claimCredit() external nonReentrant {
    // The bond must not have been claimed yet.
    if (bondClaimed) revert NoCreditToClaim();

    // The game must have resolved or 14 days have passed since creation.
    // 14 days chosen as the proof system should have progressed enough so this can't update the
    // anchor state registry anymore.
    if (expectedResolution.raw() != type(uint64).max) {
        if (resolvedAt.raw() == 0) revert GameNotResolved();   // <-- BUG: always reverts, escape never reached
    } else {
        if (block.timestamp < createdAt.raw() + 14 days) revert GameNotOver();
    }
    ...
}

Why this is vulnerable: After ZK nullification, expectedResolution is set to now + 7 days (a real timestamp, not type(uint64).max), so the code always enters the first branch and reverts with GameNotResolved. The 14-day escape hatch in the else branch - which was designed as a last-resort bond recovery - is only reachable when expectedResolution == type(uint64).max (i.e. zero proofs submitted). The case of one remaining proof with a permanently paused verifier was never accounted for.

Impact

An honest TEE proposer who posted a legitimate L2 output root proposal and funded the required bond has their bond permanently frozen in DelayedWETH with no on-chain recovery path. This occurs without any fault by the proposer: the trigger is a ZK soundness alert in the shared verifier, which is an independent event. The bond amount is determined by DisputeGameFactory.initBonds, a value set by the protocol (expected to be in the range of ETH). Since the lock is permanent - no timeout, no admin function, no upgrade path within the game contract itself - the proposer suffers a 100% unrecoverable loss of their bond. All PROOF_THRESHOLD=2 games are affected simultaneously if the shared ZK verifier is ever nullified.

In claimCredit(), extend the 14-day escape to also apply when expectedResolution is a concrete timestamp but the game is unresolved after 14 days:

Proof of Concept

Steps to Reproduce

Run the PoC

Save the below PoC under test/multiproof/AggregateVerifier.t.sol:

Expected output

Step-by-step attack flow demonstrated by the PoC

1

1. A PROOF_THRESHOLD=2 AggregateVerifier game is created via DisputeGameFactory

2

2. The TEE prover creates the game and submits a TEE proof

  • proofCount = 1

  • 1 ETH locked in DelayedWETH

3

3. A ZK prover submits a ZK proof

  • proofCount = 2

  • expectedResolution = now + 1 day

4

4. nullify() is called with a competing ZK proof claiming a different intermediate root is correct (a soundness alert)

  • proofCount drops back to 1

  • zkVerifier.nullified is set to true permanently

  • expectedResolution is reset to now + 7 days (concrete timestamp, NOT type(uint64).max)

5

5. After 7+ days (timer expires, gameOver() == true)

  • resolve()REVERTS NotEnoughProofs (1 < 2)

  • verifyProposalProof(new ZK proof)REVERTS Nullified() (verifier is paused)

  • claimCredit()REVERTS GameNotResolved (escape hatch unreachable)

6

6. After 14+ days

  • claimCredit()STILL REVERTS GameNotResolved

  • delayedWETH.balanceOf(game) == 1 ETH - bond never moved

7

7. The game is permanently frozen

The TEE prover's bond is unrecoverable.

PoC Code

Save the below PoC under test/multiproof/AggregateVerifier.t.sol:

Was this helpful?