The off-chain proposer pipeline recovers the latest valid proposal chain, caches the recovered parent_address, generates the next output proof, and then submits the proof to L1 through DisputeGameFactory.createWithInitData().
This flow assumes the recovered parent remains valid until the L1 proposal transaction is accepted. If the parent game becomes invalid after recovery/proving but before the proposal transaction lands, AggregateVerifier.initializeWithInitData() deterministically reverts with InvalidParentGame.
The parent can be successfully challenged and resolve CHALLENGER_WINS, or it can be blacklisted/retired by the registry, while the proposer still holds a proved child output using the stale parent.
After this deterministic submit failure, the proposer treats the failure as retryable, reinserts the same proof back into state.proved, clears state.submitting, and tries the same submission again on later ticks. The configured max_retries only bounds proof-generation failures in handle_proof_result, it is not applied to submit failures.
A parent-invalidating actor in the normal dispute flow can make the official proposer repeatedly submit the same already-doomed proposal transaction against the same stale parent. Each retry consumes L1 gas and prevents that proposer process from progressing to a fresh recovery/re-proof path for the next valid parent, cause gas burn and delay dispute resolution.
Recommendation
Add a bounded submit retry counter separate from proof retries, classify deterministic terminal reverts such as InvalidParentGame as drop/reset/recover conditions instead of requeueing the same proof forever, and revalidate the cached parent immediately before submission.
Proof of Concept
steps:
A parent game P is valid when the proposer performs recovery walk.
The proposer proves the child output using cached parent_address = P.
Before the proposal transaction is included, P becomes invalid through a normal protocol action, for example a valid challenge resolves P as CHALLENGER_WINS, or the registry blacklists/retires P.
The proposer submits the child proposal through DisputeGameFactory.createWithInitData().
AggregateVerifier.initializeWithInitData() reverts with InvalidParentGame.
The proposer requeues the same proof and retries the same stale-parent submission on later ticks.
// src/multiproof/AggregateVerifier.sol
if (parentAddress() != address(ANCHOR_STATE_REGISTRY)) {
IDisputeGame parentGame = IDisputeGame(parentAddress());
// Parent game must be registered, respected, not blacklisted, not retired, and not challenged.
if (!_isValidGame(parentGame)) revert InvalidParentGame();
startingOutputRoot = Proposal({
l2SequenceNumber: parentGame.l2SequenceNumber(), root: Hash.wrap(parentGame.rootClaim().raw())
});
}
CARGO_ENCODED_RUSTFLAGS='-Clink-arg=-fuse-ld=lld' cargo test -p base-proposer --lib test_run_loop_retries_invalid_parent_reverts_beyond_max_retries -- --nocapture
running 1 test
test pipeline::tests::test_run_loop_retries_invalid_parent_reverts_beyond_max_retries ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 74 filtered out; finished in 0.00s