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

75078 bc low clock domain conflation in bond manager s estimate unlock time delays bond claims by up to 7 days after every process restart

Submitted on Apr 27th 2026 at 05:57:04 UTC by @godwinudo for Audit Comp | Base Azul

  • Report ID: #75078

  • Report Type: Blockchain/DLT

  • Report severity: Low

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

  • 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

Brief

The bond manager converts on-chain wall-clock timestamps into the process's monotonic clock domain using a subtraction that silently floors to zero whenever the process hasn't been running long enough. After any restart (deployment, crash, scaling event), bonds that are already claimable on-chain are incorrectly held in a waiting state for up to the full weth_delay period (default 7 days), locking ETH capital that should be immediately withdrawable.

Vulnerability Details

The BondManager in crates/proof/challenge/src/bond.rs manages the lifecycle of dispute game bonds. After a game is resolved and the bond is unlocked on-chain, the manager must wait for a DelayedWETH delay period (default 7 days) before it can call claimCredit() to withdraw the ETH. The manager tracks this wait using the process's monotonic clock (a timer that starts at zero when the process boots).

/// Estimates when the bond was unlocked using `resolved_at` as a
/// conservative lower bound. The unlock must have occurred after
/// resolve, so this may cause one early withdrawal attempt that
/// reverts, but is strictly better than resetting to "now" (which
/// would re-impose the full delay after every restart).
fn estimate_unlock_time(clock: &C, resolved_at: u64) -> Duration {
    Self::unix_to_monotonic(clock, resolved_at, clock.wall_clock_unix_secs())
}

The stated goal is clear: avoid re-imposing the full delay after a restart. To achieve this, they use resolved_at (which is older than the actual unlock time) as a "conservative lower bound" and convert it to monotonic time via unix_to_monotonic

The logic is: "the event happened age seconds ago, so in monotonic time it must have been at monotonic_now - age." This works correctly when the process has been running longer than the event is old.

However, after a restart, clock.now() (monotonic time) is near zero while age can be days or weeks. The saturating_sub silently floors the result to Duration::ZERO.

The zeroed unlocked_at value is stored in the bond's phase at determine_phase:

Then on every poll tick, check_delay uses this value to decide whether enough time has passed:

Since unlocked_at was floored to zero, elapsed equals the process uptime, not the real time since the bond was unlocked. The manager waits until process_uptime >= weth_delay before attempting the claim.

Consider a bond that was resolved at Unix timestamp 1,000,000. The process restarts at Unix timestamp 2,000,000:

  • age = 2,000,000 - 1,000,000 = 1,000,000 seconds (~11.5 days)

  • clock.now() = ~0 (process just started)

  • unlocked_at = 0.saturating_sub(1,000,000) = 0 (floored to zero)

Now check_delay runs:

  • elapsed = clock.now() - 0 = clock.now() (just the process uptime)

  • weth_delay = 604,800 seconds (7 days)

  • The manager waits 7 days from restart before claiming

The bond was actually claimable 4.5 days before the restart. The full 7-day wait is re-imposed from scratch.

Impact

Bond claims are delayed by up to weth_delay (7 days by default) after every process restart. ETH bonds sit locked in the DelayedWETH contract for the duration of the unnecessary wait. Funds are not permanently lost (the claim eventually succeeds once the monotonic clock catches up), but capital is frozen when it should be immediately available.

This compounds across multiple bonds. A proposer with several unlocked bonds that restarts its challenger process will have all of them enter this delayed state simultaneously, multiplying the locked capital.

Process restarts are routine (deployments, crashes, host reboots, auto-scaling). Any restart where an unlocked bond is older than the process uptime triggers this bug silently. An unlocked bond can be older than the process uptime because the bond was unlocked before the restart (that's why it's already bond_unlocked = true on-chain) and the process uptime is near zero at startup when startup_scan runs

Instead of converting wall-clock timestamps into the monotonic domain (where the conversion breaks after restart), compute the delay check directly in wall-clock time. This eliminates the clock domain conflation entirely:

This produces the correct result regardless of when the process started.

Proof of Concept

Add the following two tests to the existing test module in crates/proof/challenge/src/bond.rs and run with:

Both tests use the same on-chain state (same resolved_at, same wall clock, same weth_delay). The only difference is process uptime. The first test shows a long-running process correctly claiming the bond. The second test shows a freshly restarted process incorrectly refusing to claim the same bond.

Was this helpful?