Skip to content

fix(anvil): remove replaced tx on queued-pool replacement - #16638

Open
gomesalexandre wants to merge 1 commit into
foundry-rs:masterfrom
gomesalexandre:fix_anvil_txpool_queued_replace
Open

fix(anvil): remove replaced tx on queued-pool replacement#16638
gomesalexandre wants to merge 1 commit into
foundry-rs:masterfrom
gomesalexandre:fix_anvil_txpool_queued_replace

Conversation

@gomesalexandre

Copy link
Copy Markdown
Contributor

PendingTransactions::add_transaction (the QUEUED/future-nonce tx path) only checked for an underpriced replacement and returned an error - on the accept path it never removed the tx it was replacing. Compare to ReadyTransactions::add_transaction, which correctly calls replaced_transactions(...) to remove the old tx.

Consequence: submitting three transactions at the same (sender, nonce) with escalating gas price while the sender has a pending earlier nonce (so they land in the queued pool) stacks all three instead of releasing the earlier ones. txpool_status reports queued: 3 where geth would report 1, and two of the three can never be mined - unbounded pool growth for any gas-bumping/fee-escalation workflow.

Companion bug: anvil_dropTransaction only touched ready_transactions, silently no-op'ing ({"result":null}, read as "no such tx") on a queued transaction - unlike its siblings remove_invalid/remove_transactions_by_address, which already touch both pools.

The fix

Capture the replaced tx's hash from the immutable-borrow scope, then call self.remove(vec![replaced_hash]) after that borrow ends but before inserting the new tx's own waiting_markers/waiting_queue entries.

Order matters here: removing after insertion would delete the new tx's own waiting_markers entry instead of the old one, since both share the same provides key for the same nonce slot (remove()'s waiting_markers.remove(&tx.provides) is a plain key removal, not hash-checked) - silently disabling the underpriced-replacement check for the next replacement at that slot. Caught this via a synchronous Codex adversarial review before shipping.

anvil_dropTransaction now mirrors remove_invalid's existing both-pools removal pattern.

Testing

  • Real red-before-green: reverted the fix, confirmed both new tests fail with the exact described bug, restored the fix, confirmed green.
  • Two new integration tests in crates/anvil/tests/it/txpool.rs:
    • queued_tx_replacement_removes_old_tx - replaces a queued tx twice in a row, confirms the pool never stacks, confirms an underpriced replacement attempt is still correctly rejected after two prior replacements (the specific marker-corruption trap above), and fills the preceding nonce gap + mines to confirm mark_and_unlock's required_markers bookkeeping wasn't left in a bad state by the removal.
    • anvil_drop_transaction_removes_queued_tx - confirms dropping a queued tx actually removes it.
  • Full crates/anvil/tests/it/txpool.rs suite (8 tests) passing, plus the existing anvil_api::instant_mining_reselects_from_live_pool_after_failure (which already exercises anvil_dropTransaction on a ready tx) unaffected.
  • cargo clippy -p anvil --all-targets -- -D warnings clean, cargo fmt --check -p anvil clean (stable only - nightly, used by this repo's CI, wasn't available in this environment).
  • Synchronous Codex adversarial review: no blocking issues found; verified the fix is race-free (single write-lock held throughout add_transaction), verified remove()'s marker cleanup is complete under normal invariants, and flagged the required_markers coverage gap addressed above.

receipts

Live-verified against a real running anvil node (not just the test suite), reproducing the exact scenario: three transactions submitted at nonce 5 with escalating gas price via raw JSON-RPC.

Before fix would show queued: 3. After fix:

txpool_status -> {"pending":"0x0","queued":"0x1"}
tx1 (gas price lowest)  -> eth_getTransactionByHash: null (correctly removed)
tx2 (gas price middle)  -> eth_getTransactionByHash: null (correctly removed)
tx3 (gas price highest) -> eth_getTransactionByHash: found, pending
anvil_dropTransaction(tx3) -> returns tx3's hash (previously would have silently no-op'd, returning null, on a queued tx)
txpool_status after drop -> {"pending":"0x0","queued":"0x0"}

…d tx on anvil_dropTransaction

PendingTransactions::add_transaction (the queued/future-nonce path) only
checked for an underpriced replacement and returned an error - on the
accept path it never removed the tx it was replacing, unlike
ReadyTransactions::add_transaction which correctly does. Replacing a
queued transaction with a higher-priced one at the same (sender, nonce)
therefore stacked transactions indefinitely instead of releasing the old
one, only one of which could ever be mined.

The fix captures the replaced tx's hash before inserting the new tx's own
markers, then removes the old entry via the existing remove() path. Order
matters: removing after insertion would delete the new tx's own
waiting_markers entry instead (both share the same `provides` key for the
same nonce slot), silently disabling the underpriced check for the next
replacement at that slot.

anvil_dropTransaction had the same asymmetry: it only touched
ready_transactions, silently no-op'ing on a queued transaction, unlike its
siblings remove_invalid/remove_transactions_by_address which already
touch both pools.

Live-verified against a running anvil node (txpool_status/
eth_getTransactionByHash before and after), plus two new integration
tests covering repeated replacement, the underpriced-rejection-after-
replacement trap, and mark_and_unlock's required_markers bookkeeping
after a nonce-gap fill.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

✅ Changelog found

The deterministic check will validate the changed entry.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant