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 setup, in plain terms
- A two-proof game (
PROOF_THRESHOLD = 2) will only resolve once it has both a TEE proof and a ZK proof. Belt and braces. - The proposer's bond sits in a contract called
DelayedWETHuntil the game resolves, at which point they can pull it back withclaimCredit(). - A nullification is the emergency brake: if the ZK verifier is shown to be unsound (someone submits a competing proof for a different intermediate root), the shared ZK verifier is switched off permanently, and the game's ZK proof is thrown out, dropping the proof count back below the threshold.
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:
- The count drops to 1. Below the threshold of 2, so
resolve()reverts withNotEnoughProofs. - The ZK verifier is off for good. Submitting a fresh ZK proof reverts with
Nullified, so the count can never climb back to 2. - The resolution deadline is now a real timestamp. Nullification sets
expectedResolutionto a concrete future time, not the special sentinel value.
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 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
- An honest proposer loses everything, through no fault of their own. They posted a valid proposal and funded the bond. The trigger, a ZK soundness alert, is an entirely separate event they did not cause.
- The loss is total and permanent. No resolution, no timeout, no admin function, no upgrade path inside the game. The full bond stays in
DelayedWETHwith no on-chain way out. - It is not one game. The ZK verifier is shared, so a single nullification freezes the bonds of every two-proof game at once.
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.