75294 bc low the proposer retries an invalid cached parent instead of refreshing recovery state
Submitted on Apr 28th 2026 at 11:14:23 UTC by @Jolyon for Audit Comp | Base Azul
Report ID: #75294
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
Brief/Intro
The proposer in base/base reconstructs its next parent by walking UUID-linked dispute games from the anchor, but it does not verify that the recovered games are still valid parent candidates onchain. If a previously cached chain later becomes invalid because an ancestor resolves CHALLENGER_WINS, is blacklisted, or is retired, the proposer can keep retrying the same stale parent after submission failures instead of refreshing recovery state. This can delay or stall proposal progression until operator intervention or a later cache reset.
Vulnerability Details
The issue is caused by two coupled assumptions in the proposer’s recovery and retry flow.
First, recovery treats UUID reachability as sufficient for parent selection. In base/base, recover_latest_state() reads the anchor root and game_count, then calls forward_walk() to reconstruct the latest tip by repeatedly querying DisputeGameFactory.games(...) with canonical roots and the previous parent address. This logic caches the latest UUID-linked descendant as the next parent candidate, but it does not verify whether that recovered game is still a valid parent candidate onchain at the time of recovery.
Relevant code:
Recovery caches the latest UUID-linked descendant without checking current game validity:
base/base/crates/proof/proposer/src/pipeline.rspipeline.rs#L713-L777Forward walk relies only on canonical roots and UUID lookups:
base/base/crates/proof/proposer/src/pipeline.rspipeline.rs#L805-L895
Second, retry logic reuses cached recovery state after failure. In try_submit(), the proposer takes the cached parent_address and uses it directly for the next submission attempt. If submission fails in the ordinary SubmitOutcome::Failed path, handle_submit_result() requeues the same proof but does not invalidate or refresh state.cached_recovery. The next retry therefore uses the same stale parent again.
Relevant code:
Cached
parent_addressis used directly for submission:base/base/crates/proof/proposer/src/pipeline.rspipeline.rs#L445-L503Failed submissions requeue without refreshing recovery state:
base/base/crates/proof/proposer/src/pipeline.rspipeline.rs#L579-L589
This becomes unsafe because the supplied parent must still be valid onchain. In base/contracts, AggregateVerifier.initialize() rejects a non-anchor parent with InvalidParentGame() if _isValidGame(parentGame) is false. _isValidGame() requires the parent game to still be registered, respected, not blacklisted, not retired, and not already resolved as CHALLENGER_WINS.
Relevant code:
Parent validation during initialization:
base/contracts/src/multiproof/AggregateVerifier.solAggregateVerifier.sol#L351-L360_isValidGame()definition:base/contracts/src/multiproof/AggregateVerifier.solAggregateVerifier.sol#L952-L957
In short, the root cause is that recovery caches a descendant based only on UUID reachability, while retry logic assumes that cached parent remains valid across submission failures. Once a previously cached dispute-game chain later becomes invalid onchain, the proposer can keep retrying the same stale parent instead of forcing a fresh recovery.
Impact Details
This issue is a bug in Base’s L2 proposer / network-side code that results in unintended smart contract behavior with no concrete funds at direct risk.
Once the proposer cache contains a dispute-game descendant whose ancestry later becomes invalid onchain, subsequent proposal attempts can continue reusing that stale parent across failed submissions. Because the normal SubmitOutcome::Failed retry path does not invalidate or refresh cached_recovery, the proposer can keep retrying an invalid parent candidate while the cache key remains unchanged.
The result is not direct fund theft or a proof soundness failure. Instead, the proposer can continue attempting dispute-game creation with an onchain-invalid parent, causing unintended dispute-game submission behavior and delaying creation of the next valid game until recovery state is refreshed, the service is restarted, or another operator action occurs.
This is therefore best classified as a Medium-severity network / proposer logic bug that causes unintended smart contract behavior without concrete funds directly at risk.
Proof of Concept
Repository/revision: base/base@v0.8.0-rc.28
Apply the following additions within the existing #[cfg(test)] mod tests block in crates/proof/proposer/src/pipeline.rs.
Run:
Observed output:
This PoC intentionally mocks the submission failure path; the production invalid-parent rejection is enforced by AggregateVerifier.initialize() and _isValidGame() in base/contracts.
Was this helpful?