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

75376 sc low permanent freezing of dispute game bonds in aggregateverifier when proof threshold equals two and one proof type permanently fails

Submitted on Apr 28th 2026 at 20:01:05 UTC by @whatsrdoin589 for Audit Comp | Base Azul

  • Report ID: #75376

  • 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

    • Temporary freezing of funds for at least 24 hours (e.g., stuck withdrawal proofs, locked dispute game bonds)

Description

The bug I found is a perm/temp lockout depending on how you trigger it. In AggregateVerifier.sol the bond posted by a dispute game's creator can become locked when the game is configured with PROOF_THRESHOLD = 2 and only one of the two required proof types is ever submitted. If the alternate proof type's verifier later becomes permanently unusable, both resolve and claimCredit revert and the 14-day abandoned-game fallback inside claimCredit is unreachable. Worst case is permanent loss of the bond for affected anchor games. Best case is at least a 7 day freeze for affected mid chain games while the parent dispute settles.

Vulnerability Details

The bond claim path at AggregateVerifier.sol lines 606 to 634:

function claimCredit() external nonReentrant {
    if (bondClaimed) revert NoCreditToClaim();

    // The game must have resolved or 14 days have passed since creation.
    if (expectedResolution.raw() != type(uint64).max) {
        if (resolvedAt.raw() == 0) revert GameNotResolved();
    } else {
        if (block.timestamp < createdAt.raw() + 14 days) revert GameNotOver();
    }
    // ... DELAYED_WETH unlock and withdraw paths
}

The 14 day fallback in the else branch is documented in the source comment at lines 610-611 as the escape for when the proof system has gotten far enough that the game can no longer update the anchor state registry. That branch only executes when expectedResolution == type(uint64).max, which is only true when no proof has ever been submitted to the game.

The internal helper _proofVerifiedUpdate, called from both initializeWithInitData and verifyProposalProof, calls _decreaseExpectedResolution, which sets expectedResolution to the minimum of the new resolution timestamp and the current value:

After even one proof submission expectedResolution is no longer type(uint64).max, and from that point the only path through claimCredit is the if branch at line 614, which requires resolvedAt != 0. The resolve function at line 458 reverts when proofCount is below threshold:

Put it together: with PROOF_THRESHOLD = 2, proofCount = 1, and the alternate proof type's verifier dead, both resolve and claimCredit revert. We never get to the else portion.

The most concrete way for the alternate verifier to fail is governance nullification. Each verifier has a nullify() path documented at AggregateVerifier.sol lines 594-601 that anyone in the same game type can hit when there's a soundness break. Once nullified, the verifier permanently rejects everything because Verifier.sol lines 39-47 expose no way to reverse the flag. Every verifyProposalProof call of that proof type reverts under the notNullified modifier (Verifier.sol:27-30).

Two other failure modes produce the same lockup whihc are ZK program rotation (because ZK_RANGE_HASH and ZK_AGGREGATE_HASH are immutable, a new SP1 program version can't satisfy old games' verification at lines 927 and 932), and indefinite operator outage (compromised key or network partition).

The 14 day fallback in claimCredit was clearly intended to handle exactly this situation, but the implementation only triggers it when no proof at all was submitted. The partial-progress case (one proof in, alternate stalls) gets no safety net.

One thing worth mentioning, the resolve function at lines 447-467 includes a parent-game status check before the proofCount check. When the parent settles to CHALLENGER_WINS, this game settles to CHALLENGER_WINS, resolvedAt is set, and claimCredit succeeds. So the permanent lockup is only realized when all of these hold at the same time: the current game has PROOF_THRESHOLD = 2 with proofCount < 2, the parent (or AnchorStateRegistry parent) settles to DEFENDER_WINS, and the alternate verifier is permanently dead. Anchor games are the worst case because _getParentGameStatus returns DEFENDER_WINS unconditionally for them, so the parent-settlement escape does not exist for anchors. Mid chain games can eventually escape if some ancestor settles CHALLENGER_WINS, but the bond stays frozen until that happens.

Attack flow

1

1. The proposer creates a dispute game with PROOF_THRESHOLD = 2 and posts the bond at msg.value.

2

2. Within the SLOW_FINALIZATION_DELAY window, exactly one proof type lands. proofCount = 1. _decreaseExpectedResolution sets expectedResolution = block.timestamp + 7 days.

3

3. Some unrelated game in the same game type triggers nullify() on the OTHER proof type's verifier. (Or the verifier dies via program rotation or operator outage. Same outcome.)

4

4. Time advances past the 7-day window. gameOver() returns true. The game would normally resolve here.

5

5. Anyone calls resolve(). It reverts on NotEnoughProofs because proofCount < PROOF_THRESHOLD.

6

6. The proposer calls claimCredit() to recover their bond. The first gate sees expectedResolution != type(uint64).max (set in step 2) and takes the if branch. That branch requires resolvedAt != 0, but resolve never set it, so claimCredit reverts on GameNotResolved.

7

7. The 14-day else branch is unreachable because expectedResolution is no longer the sentinel value. Time can pass forever, the bond cannot be claimed.

Impact Details

This bug maps to two impact categories depending on what kind of game gets stuck.

Permanent freezing of dispute game bonds with no available recovery path applies to anchor games configured with PROOF_THRESHOLD = 2 when one proof type's verifier becomes permanently unusable. The bond is deposited into DELAYED_WETH at AggregateVerifier.sol line 411, and the only direct path to extract it is through claimCredit (which calls DELAYED_WETH.unlock then DELAYED_WETH.withdraw). Both calls live behind the resolved-or-14-day gate this finding describes. When both resolve and the 14 day fallback are blocked there is no inscope path to recover the bond. The bond amount equals the gameCreator's msg.value at game creation and each affected anchor game permanently loses its full bond.

Temporary freezing of funds for at least 24 hours applies to midchain games in the same configuration. The bond eventually becomes reclaimable if some ancestor in the dispute chain settles to CHALLENGER_WINS, but the bond stays frozen for the duration of the parent settlement timeline, which is multiday under normal operations. The freezing window has at minimum the SLOW_FINALIZATION_DELAY of 7 days, which is well over 24 hours.

Suggested fix

Extend the 14 day fallback in claimCredit to cover threshold-not-reached games. Update the gate so the 14 day timer also unlocks the bond when proofCount < PROOF_THRESHOLD and 14 days have elapsed since createdAt

A normal happy-path game has resolve set resolvedAt during the same call that brings proofCount up to threshold, so the stuck condition can never trigger for a healthy game. The change is non-breaking and is my preferred fix because its simple and has very little impact.

References

Upstream files at base/contracts v8.1.0

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

Specific line ranges relevant to this finding:

claimCredit: AggregateVerifier.sol 606-634 resolve: AggregateVerifier.sol 443-474 _proofVerifiedUpdate: AggregateVerifier.sol 763-770 _decreaseExpectedResolution: AggregateVerifier.sol 773-785 _getDelay: AggregateVerifier.sol 815-823 nullify and notNullified modifier: Verifier.sol 27-47 DELAYED_WETH bond storage call sites: AggregateVerifier.sol 411, 620, 626

Proof of Concept

I built a minimum reproducer that isolates the exact control flow from AggregateVerifier.sol v8.1.0 (the claimCredit, resolve, _proofVerifiedUpdate, _decreaseExpectedResolution, and _getDelay paths). The proof verification call is replaced with a no-op so the reproducer is dependency-free and the triager can run it without setting up cwia clones, DelayedWETH, AnchorStateRegistry, SP1 verifier, or NitroEnclave.

Ran 4 tests for test/Repro.t.sol:F001_BondLockupTest

[PASS] test_BondLocked_When_OnlyOneProof_And_ThresholdTwo (gas: 978910) [PASS] test_Baseline_ThresholdOne_ResolvesNormally (gas: 976593) [PASS] test_Baseline_ThresholdTwo_BothProofs_ResolvesNormally (gas: 1002165) [PASS] test_Baseline_NoProofs_FourteenDayFallbackWorks (gas: 949182) 4 passed; 0 failed; 0 skipped

The first test is the lockup itself and the other three are baselines that show the lockup does NOT manifest at threshold=1, does NOT manifest when both proofs land and that the 14 day fallback works fine for never proven games. Together they isolate the bug surface to exactly the partial progress case.

To run: save the two files below, install forge-std at lib/forge-std, and run forge test -vv. Or skip local install and run via Docker:

Was this helpful?