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

74546 sc low permanent bond lockup when proof threshold 2 after challenge nullify sequence

Submitted on Apr 23rd 2026 at 10:33:48 UTC by @New5paceXyz for Audit Comp | Base Azul

  • Report ID: #74546

  • 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

When AggregateVerifier is deployed with PROOF_THRESHOLD = 2, a challenge() call followed by a nullify() call leaves the game permanently unresolvable. resolve() is blocked by NotEnoughProofs, claimCredit() is blocked by GameNotResolved, and no new proof can ever be submitted to break the deadlock. For root games, the bond is locked forever with no recovery path. The developer's own in-code comment directly contradicts this behavior, confirming it is unintended.

Vulnerability Details

Two independent design decisions collide when PROOF_THRESHOLD = 2:

  1. resolve() unconditionally enforces proofCount >= PROOF_THRESHOLD (L458) before resolving.

  2. nullify() permanently disables the ZK verifier by calling IVerifier(ZK_VERIFIER).nullify() (L598), making it impossible to ever submit another ZK proof. At the same time, the TEE slot remains occupied, so proofCount is stuck at 1 and can never increase again.

After the sequence, the contract demands 2 proofs to resolve but can only ever hold 1. There is no code path that allows the game to exit this state.

Proof of Deadlock

1

Step 1 — initializeWithInitData()

A valid TEE proof is submitted. The game starts normally.

2

Step 2 — challenge() (L523–540)

Anyone submits a valid ZK proof disputing an intermediate root.

3

Step 3 — nullify() (L548–601)deadlock trigger

Anyone proves via ZK that the challenged intermediate root is actually correct (i.e., the ZK challenge was wrong). _proofRefutedUpdate(ZK) runs:

Then:

Game state after Step 3:

Every Exit Path Is Blocked

resolve() — blocked by NotEnoughProofs

After 7 days, gameOver() returns true. The parent game is DEFENDER_WINS for a root game, so execution takes the else branch at L455. The check at L458 fires:

claimCredit() — blocked by GameNotResolved

claimCredit() has a 14-day fallback, but it only activates when expectedResolution == type(uint64).max (L613). After Step 3, expectedResolution = block.timestamp + 7 days because _getDelay(proofCount=1) returns SLOW_FINALIZATION_DELAY, not type(uint64).max. The 14-day branch is never reached:

verifyProposalProof(ZK) — blocked by nullified verifier

The proofTypeToProver[ZK] slot is empty, so the AlreadyProven guard passes. But _verifyProof() calls ZK_VERIFIER.verify(), which hits the notNullified modifier and reverts permanently.

verifyProposalProof(TEE) — blocked by AlreadyProven

proofTypeToProver[TEE] is still set to gameCreator(). The check at L426 reverts immediately:

challenge() again — blocked by nullified verifier

challenge() internally calls _verifyProof() with a ZK proof type. This reaches ZK_VERIFIER.verify() and reverts for the same reason as above.

Impact Details

  • The TEE proposer, who submitted a correct and honest proof, permanently loses their bond — not because they did anything wrong, but because the ZK challenger was wrong.

  • The bond is not redistributed to any party. It is simply locked forever.

  • Once nullify() is called, the ZK_VERIFIER is permanently disabled for all games that share the same instance — the impact extends beyond a single game.

  • Root games (parentAddress == ANCHOR_STATE_REGISTRY) have absolutely no escape. Non-root games have a narrow escape only if their parent resolves as CHALLENGER_WINS.

References

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

Proof of Concept

  1. Deploy AggregateVerifier with PROOF_THRESHOLD = 2.

  2. Call initializeWithInitData() with a valid TEE proof. Observe proofCount = 1.

  3. Call challenge() with a valid ZK proof for any intermediate root. Observe proofCount = 2.

  4. Call nullify() with a ZK proof proving the same intermediate root is correct. Observe proofCount = 1, ZK_VERIFIER.nullified = true.

  5. Wait 7 days. Call resolve(). Observe revert: NotEnoughProofs.

  6. Call claimCredit(). Observe revert: GameNotResolved.

  7. Attempt verifyProposalProof() with any proof type. Both revert (ZK: nullified verifier; TEE: AlreadyProven).

  8. Bond is permanently locked.

Was this helpful?