74911 sc low aggregateverifier bond permanently locked when proof threshold 2 and a verifier is nullified resolve and claimcredit both revert
Submitted on Apr 25th 2026 at 20:57:03 UTC by @Meadowlark14896 for Audit Comp | Base Azul
- Report ID: #74911
- Report Type: Smart Contract
- Report severity: Low
- Target: https://github.com/base/contracts/tree/v8.1.0/src/multiproof
- Impacts:
- Temporary freezing of funds for at least 24 hours (e.g., stuck withdrawal proofs, locked dispute game bonds)
Description
Summary
When PROOF_THRESHOLD=2 (a value explicitly supported by the constructor validation at line 285), if either the TEE or ZK verifier is nullified, the game enters an unresolvable state where the bond is permanently locked. The resolve() function requires proofCount >= PROOF_THRESHOLD, but once a verifier is nullified, proofCount can never reach 2 again. Meanwhile, claimCredit() requires the game to be resolved (since expectedResolution != type(uint64).max), creating a deadlock where the bond cannot be claimed by anyone.
Root Cause
In AggregateVerifier.sol, three conditions create the deadlock:
- Nullification kills a verifier permanently (lines 594-601):
TEE_VERIFIER.nullify()orZK_VERIFIER.nullify()sets a globalnullifiedflag with no undo mechanism. No further proofs of that type can be verified. resolve()requiresproofCount >= PROOF_THRESHOLD(line 458): With one verifier dead andPROOF_THRESHOLD=2, the game can never accumulate 2 proofs.claimCredit()14-day fallback only works whenexpectedResolution == type(uint64).max(lines 613-617): After a proof is submitted (movingexpectedResolutionaway frommax), the fallback path is disabled, and the function requiresresolvedAt != 0.
// Line 458 — resolve() blocks
if (proofCount < PROOF_THRESHOLD) revert NotEnoughProofs();
// Lines 613-617 — claimCredit() blocks
if (expectedResolution.raw() != type(uint64).max) {
if (resolvedAt.raw() == 0) revert GameNotResolved(); // ← ALWAYS REVERTS
} else {
if (block.timestamp < createdAt.raw() + 14 days) revert GameNotOver();
}
Attack Scenarios
Path A — TEE nullified, then ZK proof submitted
- Game created with TEE proof →
proofCount=1,expectedResolution = now + 7 days - TEE proof nullified →
proofCount=0,expectedResolution = type(uint64).max,TEE_VERIFIERdead - ZK proof submitted via
verifyProposalProof→proofCount=1,expectedResolution = now + 7 days - After 7 days:
gameOver() = true resolve()→proofCount(1) < PROOF_THRESHOLD(2)→ REVERTSclaimCredit()→expectedResolution != max→resolvedAt == 0→ REVERTS- Bond permanently locked
Path B — ZK nullified after both proofs
- TEE proof →
proofCount=1 - ZK proof →
proofCount=2 - ZK nullified →
proofCount=1,ZK_VERIFIERdead - Cannot re-submit TEE (
AlreadyProven) or ZK (notNullifiedmodifier) resolve()→proofCount(1) < PROOF_THRESHOLD(2)→ REVERTS- Bond permanently locked
Path C — Challenge ZK nullified
- TEE proof →
proofCount=1 - Challenge with ZK →
proofCount=2,counteredByIntermediateRootIndexPlusOne > 0 - ZK nullified (proves on-chain root correct) →
proofCount=1,ZK_VERIFIERdead resolve()→proofCount(1) < PROOF_THRESHOLD(2)→ REVERTS- Bond permanently locked
Impact
Permanent freezing of the proposer's bond. The only recovery path is DelayedWETH.recover() which requires the DelayedWETH owner (a multisig) to manually intervene. The bondRecipient (proposer or challenger) has no way to claim their funds.
The code explicitly supports PROOF_THRESHOLD=2 via the constructor validation:
if (proofThreshold != 1 && proofThreshold != 2) revert InvalidProofThreshold();
This means the protocol intends to support dual-proof configurations. Any deployment with PROOF_THRESHOLD=2 is vulnerable to permanent bond locking whenever a verifier is nullified.
Proof of Concept
Place in test/multiproof/ and run with forge test --match-test "test_BUG" -vvv:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Test, console} from "forge-std/Test.sol";
import {BaseTest} from "test/multiproof/BaseTest.t.sol";
import {AggregateVerifier} from "src/multiproof/AggregateVerifier.sol";
import {Claim, GameStatus, GameType, Hash, Proposal, Timestamp} from "src/dispute/lib/Types.sol";
import {IDisputeGame} from "interfaces/dispute/IDisputeGame.sol";
import {IVerifier} from "interfaces/multiproof/IVerifier.sol";
import {IAnchorStateRegistry} from "interfaces/dispute/IAnchorStateRegistry.sol";
import {IDelayedWETH} from "interfaces/dispute/IDelayedWETH.sol";
import {MockVerifier} from "src/multiproof/mocks/MockVerifier.sol";
contract ProofThreshold2Test is BaseTest {
// Override PROOF_THRESHOLD to 2
function setUp() public override {
// Deploy everything manually with PROOF_THRESHOLD=2
_deployContractsAndProxies();
_initializeProxies();
// Deploy AggregateVerifier with PROOF_THRESHOLD=2
AggregateVerifier aggregateVerifierImpl = new AggregateVerifier(
AGGREGATE_VERIFIER_GAME_TYPE,
IAnchorStateRegistry(address(anchorStateRegistry)),
IDelayedWETH(payable(address(delayedWETH))),
IVerifier(address(teeVerifier)),
IVerifier(address(zkVerifier)),
TEE_IMAGE_HASH,
AggregateVerifier.ZkHashes(ZK_RANGE_HASH, ZK_AGGREGATE_HASH),
CONFIG_HASH,
L2_CHAIN_ID,
BLOCK_INTERVAL,
INTERMEDIATE_BLOCK_INTERVAL,
2 // PROOF_THRESHOLD = 2
);
factory.setImplementation(AGGREGATE_VERIFIER_GAME_TYPE, IDisputeGame(address(aggregateVerifierImpl)));
factory.setInitBond(AGGREGATE_VERIFIER_GAME_TYPE, INIT_BOND);
anchorStateRegistry.setRespectedGameType(AGGREGATE_VERIFIER_GAME_TYPE);
vm.warp(block.timestamp + 1);
}
function test_BUG_bondLockedWhenVerifierNullified() public {
// Step 1: Create game with TEE proof
currentL2BlockNumber += BLOCK_INTERVAL;
Claim rootClaim = Claim.wrap(keccak256(abi.encode(currentL2BlockNumber)));
bytes memory teeProof = _generateProof("tee", AggregateVerifier.ProofType.TEE);
AggregateVerifier game = _createAggregateVerifierGame(
TEE_PROVER, rootClaim, currentL2BlockNumber, address(anchorStateRegistry), teeProof
);
assertEq(game.proofCount(), 1);
console.log("1. Game created with TEE proof, proofCount=1");
// Step 2: Submit ZK proof
bytes memory zkProof = _generateProof("zk", AggregateVerifier.ProofType.ZK);
vm.prank(ZK_PROVER);
game.verifyProposalProof(zkProof);
assertEq(game.proofCount(), 2);
console.log("2. ZK proof submitted, proofCount=2");
// Step 3: ZK verifier is nullified (simulates ZK compromise detection)
zkVerifier.setNullified(true);
// Simulate nullify call effect on the game
// In reality this happens via game.nullify() but we need a valid proof
// Instead, let's demonstrate the state: if proofCount drops to 1 and ZK is dead
// For this PoC, we directly show the deadlock condition:
// After nullification, proofCount=1, PROOF_THRESHOLD=2, ZK_VERIFIER is dead
// Step 4: Wait for game to be over
vm.warp(block.timestamp + 7 days + 1);
assertTrue(game.gameOver(), "Game should be over");
// With proofCount=2, resolve works fine
game.resolve();
assertEq(uint8(game.status()), uint8(GameStatus.DEFENDER_WINS));
console.log("3. This game resolves (proofCount=2 >= threshold=2)");
// Now demonstrate the BROKEN case: create a new game
currentL2BlockNumber += BLOCK_INTERVAL;
rootClaim = Claim.wrap(keccak256(abi.encode(currentL2BlockNumber)));
teeProof = _generateProof("tee2", AggregateVerifier.ProofType.TEE);
AggregateVerifier game2 = _createAggregateVerifierGame(
TEE_PROVER, rootClaim, currentL2BlockNumber, address(game), teeProof
);
assertEq(game2.proofCount(), 1);
console.log("4. New game created with TEE proof, proofCount=1");
// ZK verifier is nullified — cannot submit ZK proof
bytes memory zkProof2 = _generateProof("zk2", AggregateVerifier.ProofType.ZK);
vm.prank(ZK_PROVER);
vm.expectRevert(); // notNullified modifier blocks this
game2.verifyProposalProof(zkProof2);
console.log("5. ZK proof BLOCKED by notNullified — proofCount stuck at 1");
// Wait for game to be over
vm.warp(block.timestamp + 7 days + 1);
assertTrue(game2.gameOver());
// resolve() fails — proofCount < PROOF_THRESHOLD
vm.expectRevert(AggregateVerifier.NotEnoughProofs.selector);
game2.resolve();
console.log("6. resolve() REVERTS: proofCount(1) < PROOF_THRESHOLD(2)");
// claimCredit() fails — game not resolved
vm.expectRevert(abi.encodeWithSignature("GameNotResolved()"));
game2.claimCredit();
console.log("7. claimCredit() REVERTS: game not resolved");
// Even after 14 days — still fails because expectedResolution != max
vm.warp(block.timestamp + 14 days);
vm.expectRevert(abi.encodeWithSignature("GameNotResolved()"));
game2.claimCredit();
console.log("8. claimCredit() STILL REVERTS after 14 days");
console.log("");
console.log("RESULT: Bond is permanently locked. No recovery path for bondRecipient.");
}
}
Why This Is Not a Duplicate
- This is a code logic bug in
AggregateVerifier.sol(src/multiproof/), not a deployment configuration issue. - Not listed in the Known Vulnerabilities PDF.
- Not related to the
anchorGamereset issue or any other previously reported finding. - The code explicitly supports
PROOF_THRESHOLD=2via constructor validation.
Recommendation
Add a recovery mechanism when a verifier is nullified. For example, reduce PROOF_THRESHOLD dynamically when a verifier is nullified, or allow claimCredit() to use the 14-day fallback regardless of expectedResolution:
function claimCredit() external nonReentrant {
if (bondClaimed) revert NoCreditToClaim();
// Allow 14-day fallback regardless of expectedResolution
bool gameResolved = resolvedAt.raw() != 0;
bool fallbackExpired = block.timestamp >= createdAt.raw() + 14 days;
if (!gameResolved && !fallbackExpired) {
revert GameNotResolved();
}
// ... rest of function
}
Link to Proof of Concept
https://gist.github.com/bigfootbjj/4c22790fc3dc4dfb30c06cf55292fa30