Skip to content

fix(txpool): stop judging nonce-gapped MorphTx against a cumulative budget - #203

Closed
panos-xyz wants to merge 1 commit into
fix/txpool-maintenance-safetyfrom
fix/txpool-maintenance-nonce-gap
Closed

panos-xyz wants to merge 1 commit into
fix/txpool-maintenance-safetyfrom
fix/txpool-maintenance-nonce-gap

Conversation

@panos-xyz

Copy link
Copy Markdown
Contributor

Stacked on #202 — review that one first; this PR's base is its branch, so the diff here is just the nonce-gap change.

The problem

The revalidation walk applies one rolling sender budget across every MorphTx in the pool, pending and queued alike. A transaction behind a nonce gap is therefore charged against whatever the sender's executable transactions left over.

That number is meaningless. The transactions filling the gap are not in the pool, so how much of the balance is still owed by the time the gapped transaction executes is unknown — it could be more (the gap transactions spend) or less (they arrive with a refund, or never arrive at all and the gap closes with a cheaper replacement). An unrelated, otherwise empty block was enough to evict a future-nonce transaction that had passed admission on its own merits.

The fix

Stop the walk at the first nonce discontinuity.

This is what upstream already does — AllTransactions::update, pool/txpool.rs:

// If there's a nonce gap, we can shortcircuit, because there's nothing to update yet.
if tx.state.has_nonce_gap() {
    next_sender!(iter);
    continue 'transactions
}

go-ethereum arrives at the same place from the other direction: promoteExecutables only ever applies a per-transaction cost check to the queue (core/tx_pool.go:1622) and discards FilterF's invalids return value — it never runs a cumulative budget over future nonces.

Nothing is lost by leaving these transactions alone. Without NO_NONCE_GAPS they live in the queued sub-pool, which is precisely what reth's own stale eviction reaps after max_tx_lifetime.

Tests

Both confirmed to fail before this change:

  • a_transaction_behind_a_nonce_gap_is_not_charged_to_the_budget — nonce 0 reserves the sender's whole token balance, nonce 10 is behind a gap; previously nonce 10 was removed.
  • a_sender_holding_only_future_nonces_is_left_alone — a sender with no executable front is not evaluated at all, even with a zero token balance.

make lint, cargo test --all and cargo test --doc pass.

Why the other half of the review finding is not here

The external review also suggested not removing a transaction that is merely unaffordable right now, leaving that to the payload builder (which already skips it via mark_invalid, correctly and for that block only).

That was not done, deliberately. Upstream's stale eviction only scans queued_transactions() — the basefee and queued sub-pools. A transaction in pending is never time-evicted, so a token-fee transaction whose sender has spent their balance would occupy a pool slot forever, keep being gossiped, and keep skewing eth_getTransactionCount(pending).

reth's own answer for the ETH equivalent is park, then reap: update_accounts clears ENOUGH_BALANCE, the transaction moves to queued, and stale eviction takes it from there. There is no public API to park a specific transaction, so removing the first unaffordable transaction and letting the pool park its descendants — which #202 makes it do — is the closest available approximation, and matches go-ethereum's demoteUnexecutables outcome.

https://claude.ai/code/session_01PiUjd47Da71WG2BFkDQz9q

…udget

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
@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 7ca2d03f-81bf-441b-9c17-3f3fa0104802

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Warning

Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@panos-xyz

Copy link
Copy Markdown
Contributor Author

Folded into #200 at the author's request — the commit is preserved there unchanged (cherry-picked, -x trailer intact). Branch kept for now; delete once #200 merges.

@panos-xyz panos-xyz closed this Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant