← Writeups

A frozen bond in Base's dispute game: the escape hatch that could not open

An honest proposer locks a bond to back their proposal, and a safety timeout is supposed to give it back if the game ever gets stuck. A ZK verifier nullification stuck the game in the one state that timeout was gated against, and the bond stayed locked forever.

When Base settles an L2 result on L1, a proposer puts up an output root and locks a bond behind it, real ETH, as skin in the game. Proof systems then confirm or challenge the proposal, and once things settle the proposer gets their bond back. The code is careful to include a last-resort safety timeout: if a game somehow never resolves, after enough days the proposer can still reclaim their bond, so honest money is never trapped. This finding is about that safety timeout being unreachable in exactly the situation it exists for. It was submitted to the Base Azul audit competition and selected as the lead report.

The Immunefi submission #74351 for Base Azul: Bond Permanently Locked After ZK Verifier Nullification in PROOF_THRESHOLD=2 Game, marked Low, Paid, Audit Competition, Chief Finding
Immunefi submission #74351 on the Base Azul audit competition: Low, paid, and marked a Chief Finding.

The setup, in plain terms

The state the game gets stuck in

Walk the sequence. A proposer creates a two-proof game with a TEE proof (count = 1) and funds the bond. A ZK proof arrives (count = 2), and the game schedules its resolution. Then the ZK verifier is nullified. Three things happen at once, and together they wall the game in:

Resolve is impossible and a new proof is impossible. That leaves the safety timeout, and this is where it falls apart.

The escape hatch, gated shut

Here is the recovery path in claimCredit():

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

    if (expectedResolution.raw() != type(uint64).max) {
        if (resolvedAt.raw() == 0) revert GameNotResolved();     // <-- always taken after nullification
    } else {
        // the 14-day last-resort recovery lives ONLY in this branch
        if (block.timestamp < createdAt.raw() + 14 days) revert GameNotOver();
    }
    ...
}
The recovery is in the wrong branch

The 14-day "give the bond back no matter what" timeout lives in the else branch, which only runs when expectedResolution is the sentinel type(uint64).max, the state of a game that never got any proofs. But nullification sets expectedResolution to a concrete timestamp, so claimCredit() always takes the first branch, sees the game is unresolved, and reverts with GameNotResolved. Fourteen days, forty days, forever, the answer never changes. The one case the code did not plan for, one proof left and a permanently paused verifier, is the one where an honest proposer needs the hatch, and it is bolted shut.

Why it matters

Proof of concept

The test builds a PROOF_THRESHOLD = 2 game, walks it into the stuck state, then shows every exit reverting, even 14 days later, with the bond still sitting in DelayedWETH.

function test_poc_bondPermanentlyLockedAfterZKNullification() public {
    // 1. TEE prover creates the two-proof game and funds the bond
    AggregateVerifier game = _createGame2(TEE_PROVER, rootClaim, ...);
    assertEq(game.proofCount(), 1);
    assertEq(delayedWETH.balanceOf(address(game)), INIT_BOND);   // bond locked

    // 2. ZK proof arrives -> count = 2
    _provideProof(game, ZK_PROVER, zkProof);
    assertEq(game.proofCount(), 2);

    // 3. Soundness alert: nullify with a competing intermediate root
    game.nullify(nullifyProofBytes, 0, differentIntermediateRoot);
    assertEq(game.proofCount(), 1);                              // back below threshold
    assertTrue(zkVerifier.nullified());                         // verifier off for good
    assertNotEq(game.expectedResolution().raw(), type(uint64).max);  // concrete timestamp

    // 4. Timer expires, and every exit is blocked
    vm.warp(block.timestamp + 7 days + 1);
    vm.expectRevert(AggregateVerifier.NotEnoughProofs.selector);
    game.resolve();                                             // cannot resolve

    vm.expectRevert();                                          // verifier nullified
    game.verifyProposalProof(newZkProof);                      // cannot add a proof

    vm.expectRevert(GameNotResolved.selector);
    game.claimCredit();                                        // cannot recover

    // 5. Even after 14 more days, the escape hatch never opens
    vm.warp(block.timestamp + 14 days);
    vm.expectRevert(GameNotResolved.selector);
    game.claimCredit();

    assertEq(delayedWETH.balanceOf(address(game)), INIT_BOND);  // BOND PERMANENTLY LOCKED
    assertEq(game.resolvedAt().raw(), 0);                      // no path to resolution exists
}
[PASS] test_poc_bondPermanentlyLockedAfterZKNullification() (gas: 659618)
Suite result: ok. 1 passed; 0 failed; 0 skipped

The fix

Make the last-resort timeout reachable whenever the game is stuck unresolved, not only in the no-proofs sentinel case. Add the same 14-day check inside the concrete-timestamp branch:

if (expectedResolution.raw() != type(uint64).max) {
    if (resolvedAt.raw() == 0) {
        // allow recovery after 14 days even if stuck, e.g. verifier nullified
        if (block.timestamp < createdAt.raw() + 14 days) revert GameNotResolved();
    }
} else {
    if (block.timestamp < createdAt.raw() + 14 days) revert GameNotOver();
}

Now an unresolved game returns the bond after the same 14 days regardless of how it got stuck, and the safety net actually catches the case it was built for.

Key takeaways

  • A safety timeout is only real if it is reachable from every stuck state, not just the one the author pictured.
  • Emergency actions change state in ways the happy path does not expect. Trace what a nullification, a pause, or a revert leaves behind, and check the recovery paths still fire.
  • Gating a recovery on a sentinel value (type(uint64).max) quietly excludes every state where that value has been overwritten.
  • Frozen-funds bugs do not need an attacker with a profit motive. An honest user plus an unlucky, independent event is enough, and the money is just as gone.
Frozen Funds Dispute Game Smart Contract L2
Share on X

More writeups