From 65ab7ecd7f5dfd3e57ef49f9bb2dde6a335fb2a5 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Fri, 11 Sep 2026 15:08:32 +0800 Subject: [PATCH] fix(txpool): stop judging nonce-gapped MorphTx against a cumulative budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The revalidation walk applied one rolling sender budget across every MorphTx in the pool, pending and queued alike. A transaction sitting behind a nonce gap was therefore charged against whatever the sender's executable transactions had left over — but the transactions filling the gap are not in the pool, so how much of the balance is actually still owed by the time the gapped one executes is unknown. An unrelated block was enough to evict a future-nonce transaction that had passed admission on its own. Stop the walk at the first nonce discontinuity, which is what upstream's `AllTransactions::update` does ("If there's a nonce gap, we can shortcircuit, because there's nothing to update yet"). go-ethereum reaches the same place from the other direction: `promoteExecutables` only ever applies a per-transaction cost check to the queue and discards `FilterF`'s `invalids`. Nothing is lost by leaving those transactions alone: without `NO_NONCE_GAPS` they sit in the queued sub-pool, which is exactly what reth's own stale eviction reaps. Claude-Session: https://claude.ai/code/session_01PiUjd47Da71WG2BFkDQz9q --- crates/txpool/src/maintain.rs | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/txpool/src/maintain.rs b/crates/txpool/src/maintain.rs index 277c083..243dcbc 100644 --- a/crates/txpool/src/maintain.rs +++ b/crates/txpool/src/maintain.rs @@ -179,6 +179,8 @@ fn collect_removable_transactions( eth_balance: account.balance, token_balances: HashMap::new(), }; + // The nonce the next executable transaction of this sender must carry. + let mut next_nonce_in_line = account.nonce; for tx in sender_txs { // Access the consensus tx by reference (via Deref chain) instead of @@ -194,6 +196,18 @@ fn collect_removable_transactions( continue; } + // Nonce gap: the transactions filling it are not in the pool, so how much of + // this sender's balance is still owed by the time this one executes is unknown, + // and nothing from here on is executable anyway. Upstream's + // `AllTransactions::update` short-circuits the sender on a gap for the same + // reason, and go-ethereum only ever applies a per-transaction cost check to its + // queue, never a cumulative one. Anything left behind the gap sits in the queued + // sub-pool, where reth's own stale eviction reaps it. + if consensus_tx.nonce() != next_nonce_in_line { + break; + } + next_nonce_in_line = next_nonce_in_line.saturating_add(1); + if exceeds_block_gas_limit(consensus_tx.gas_limit(), block_gas_limit) { tracing::debug!( target: "morph::txpool::maintain", @@ -706,6 +720,32 @@ mod tests { assert_eq!(removable(&mut db, vec![&tx0, &tx1]), vec![*tx1.hash()]); } + #[test] + fn a_transaction_behind_a_nonce_gap_is_not_charged_to_the_budget() { + // nonce 0 is executable and reserves the sender's whole token balance. nonce 10 sits + // behind a gap, so nonces 1..9 — which are not in the pool — decide what is actually + // left by the time it executes. Judging it against the residue of nonce 0 alone is + // meaningless, and removing it on that basis destroys a transaction that passed + // admission on its own. + let mut db = test_state(0, 0, TX_TOKEN_BUDGET); + let (tx0, gapped) = (token_fee_tx(0), token_fee_tx(10)); + + assert!( + removable(&mut db, vec![&tx0, &gapped]).is_empty(), + "a nonce-gapped transaction has no meaningful cumulative budget" + ); + } + + #[test] + fn a_sender_holding_only_future_nonces_is_left_alone() { + // Nothing this sender holds is executable at the current state nonce, so there is no + // executable front to evaluate — not even for a sender that now holds no tokens. + let mut db = test_state(0, 0, 0); + let gapped = token_fee_tx(5); + + assert!(removable(&mut db, vec![&gapped]).is_empty()); + } + /// Fails every storage read of the fee token, leaving the rest of the state readable. #[derive(Debug)] struct UnreadableToken(CacheDB);