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

75479 bc low bondmanager retries premature claimcredit withdrawal on every poll after restart recovery

Submitted on Apr 29th 2026 at 11:45:47 UTC by @Razkky for Audit Comp | Base Azul

  • Report ID: #75479

  • Report Type: Blockchain/DLT

  • Report severity: Low

  • Target: https://github.com/base/base/tree/v0.8.0-rc.28

  • Impacts:

    • A bug in the respective layer 0/1/2 network code that results in unintended smart contract behavior with no concrete funds at direct risk

Description

Summary

When the base-challenger restarts or performs a periodic full rescan, BondManager re-evaluates the phase of each tracked game by calling determine_phase(). For games where the bond is already unlocked in DelayedWETH but not yet withdrawn, determine_phase() estimates the unlock timestamp from the game's resolved_at field because the actual DelayedWETH unlock time is not accessible through the game interface. If enough wall-clock time has elapsed since game resolution to make this estimate appear older than weth_delay, the game is immediately placed into AwaitingDelay with a stale timestamp, and on the next poll check_delay() advances it to NeedsWithdraw.

When the real DelayedWETH delay from the actual unlock has not yet elapsed, the claimCredit() withdrawal transaction reverts. Because the error handler in submit_claim_credit() returns Ok(None) without resetting the phase back to AwaitingDelay, the game remains in NeedsWithdraw indefinitely. Every subsequent poll retries the same premature withdrawal until the real delay finally elapses.

With the default 12-second challenger poll interval, a single game with one hour of remaining real delay generates approximately 300 reverting transactions. Multiple affected games multiply the impact proportionally.

Affected component: base/basecrates/proof/challenge/src/bond.rs (offchain challenger)

Vulnerability Details

Background: Bond Claim Lifecycle

The challenger bond module implements the following lifecycle for claiming a bond after a game resolves:

DelayedWETH requires two separate claimCredit() calls: the first triggers the unlock and starts a delay timer, the second completes the withdrawal once that delay has elapsed. The manager must not submit the second call until the delay from the actual first call has fully passed.

Root Cause

The bug has two entry points that converge on the same missing phase reset.


Entry Point 1 — determine_phase(): Primary recovery path (restart or periodic rescan)

startup_scan() is called on every challenger startup. discover_claimable_games() performs a periodic full rescan every discovery_interval. Both call evaluate_bond_range(), which calls determine_phase() for each game:

When bond_unlocked == true, the function estimates unlocked_at from resolved_at (the Unix timestamp at which the game resolved on-chain). The actual unlock timestamp is not exposed through the game interface, so resolved_at is used as a proxy. The game is then inserted directly into AwaitingDelay { unlocked_at: stale_estimate } — bypassing NeedsResolve and NeedsUnlock entirely.


Entry Point 2 — try_unlock(): Secondary path (external actor unlocks bond while challenger is running)

If an external actor submits the first claimCredit() while the challenger has the game tracked as NeedsUnlock, try_unlock() will find bond_unlocked == true on its next call and make the same estimation error:


Common estimation function

Both paths call the same estimate_unlock_time():

This computes unlocked_at = monotonic_now - (wall_now - resolved_at). Because the actual unlock must have occurred after resolution (actual_unlock_time > resolved_at), the estimate is always earlier than the truth. The older resolved_at is relative to the actual unlock, the more the estimate overstates elapsed delay time.


Premature NeedsWithdraw transition

check_delay() compares monotonic_now - unlocked_at against weth_delay:

Because unlocked_at derives from resolved_at rather than the real unlock time, elapsed equals wall_now - resolved_at (the age since resolution), not wall_now - actual_unlock_time (the age since the actual unlock). When the age since resolution exceeds weth_delay but the age since the actual unlock does not, the game is moved to NeedsWithdraw prematurely.


No phase reset on reverted withdrawal

try_withdraw() submits the second claimCredit() call and delegates error handling to submit_claim_credit():

When DelayedWETH rejects the call because the real delay has not elapsed, submit_claim_credit() handles the error as follows:

Ok(None) signals no state change. On the next poll try_withdraw() is called again, bond_claimed is still false, and another premature claimCredit() is submitted. This loop repeats every poll interval until the real DelayedWETH delay elapses.

Trigger Conditions

The bug fires when all three conditions hold simultaneously:

  1. bond_unlocked == true and bond_claimed == false — bond unlocked in a prior run (or by an external actor), withdrawal not yet complete.

  2. wall_clock_now - resolved_at >= weth_delay — enough time has elapsed since game resolution that the stale estimate treats the delay as expired.

  3. wall_clock_now - actual_unlock_time < weth_delay — the real delay from the actual first claimCredit() call has not yet elapsed.

Why these conditions are practical: The window for all three to hold simultaneously is [resolved_at + weth_delay, actual_unlock_time + weth_delay). With a 7-day weth_delay (the value deployed on mainnet), and the unlock happening shortly after resolution (e.g., within an hour), this window is approximately 6 days and 23 hours wide. Any challenger restart or periodic full rescan that falls within this window will trigger the bug. The condition does not require attacker action — normal operations (crash, restart, infrastructure maintenance) are sufficient.

Impact

Each reverting claimCredit() transaction:

  • Consumes gas paid by the challenger operator.

  • Appears as a failed transaction in the block history.

  • Emits a STATUS_ERROR metric increment with no backoff delay.

The number of reverting transactions for a single game is:

At the default 12-second poll interval with one hour of remaining real delay: approximately 300 reverting transactions per game. Multiple affected games multiply the gas cost proportionally. The challenger continues operating normally for other phases; bonds are not lost and are eventually withdrawn correctly once the real delay elapses. This is gas griefing and operational degradation of the bond-claiming path.

The immediate fix is in submit_claim_credit(). On a failed withdrawal step, reset the phase to AwaitingDelay with the current monotonic time as the new unlocked_at. This re-imposes the full delay from the point of failure and prevents the next poll from retrying immediately:

This fix bounds the retry rate to at most once per weth_delay period regardless of estimation error, eliminating the flooding behavior.

A more precise long-term fix is to read the actual unlock timestamp from DelayedWETH directly. The contract stores withdrawals[recipient][game].timestamp after the first claimCredit() call. Reading this value in determine_phase() and try_unlock() instead of deriving it from resolved_at eliminates the estimation error at its source and avoids all premature withdrawal attempts after recovery.

Proof of Concept

The test below is added to crates/proof/challenge/src/bond.rs inside the existing #[cfg(test)] mod tests block. It uses the existing FixedClock, MockAggregateVerifier, and MockBondTransactionSubmitter infrastructure already present in the crate.

The test exercises the primary bug path: a game is recovered into AwaitingDelay directly via the stale estimate (as determine_phase() does during startup_scan()), immediately transitions to NeedsWithdraw because the estimated delay appears elapsed, and then retries the premature withdrawal on every subsequent poll.

Prerequisite: protoc must be installed (or PROTOC must point to a binary), because base-zk-client compiles proto/zk_prover.proto during build.

Run command:

Clock setup:

Variable
Value
Meaning

wall_unix

2_000_000_000

Current wall-clock Unix time

resolved_at

1_999_996_400

Game resolved 3,600 s (1 h) ago

monotonic

3_700 s

Challenger has been running 3,700 s

weth_delay

3_600 s

1-hour delay (shortened from 7 days for test speed)

estimated unlocked_at

3,700 − 3,600 = 100 s

Derived from resolved_at via estimate_unlock_time

elapsed at check_delay

3,700 − 100 = 3,600 s ≥ 3,600 s

Delay appears elapsed → NeedsWithdraw

Test:

Expected output:

The test was confirmed passing against the current codebase.

Was this helpful?