Skip to content

mempool: verify scripts before evicting, and make eviction linear - #942

Merged
bkeroack merged 4 commits into
release/0.6from
fix/mempool-full-pool-eviction
Oct 14, 2026
Merged

bkeroack merged 4 commits into
release/0.6from
fix/mempool-full-pool-eviction

Conversation

@bkeroack

@bkeroack bkeroack commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Problem

When the pool was at its -maxmempool limit, accept_transaction evicted entries to make room for the incoming transaction before it checked that transaction's scripts. A transaction whose scripts then failed had already removed entries, raised the rolling minimum fee, and its removals were never reported. Bitcoin Core runs PolicyScriptChecks and ConsensusScriptChecks before it adds the transaction and trims the pool (src/validation.cpp:1382-1405, AcceptSingleTransactionInternal → FinalizeSubpackage → LimitMempoolSize), so in Core a transaction whose scripts fail changes nothing.

The eviction itself (evict_lowest_fee_entries) sorted the whole class and then, for every evicted entry and every descendant, scanned every entry in the pool, testing each one against a Vec of what had already been chosen: roughly n·E² comparisons under the pool's write lock. In a debug build, evicting 600 entries from a 20,000-entry pool took 40 s.

The same path had four more problems:

  • Eviction could take the incoming transaction's own parent. The child was then inserted spending an output nothing provided, and the template could never include it. Core's trim removes a parent together with the child that pays for it (the worst chunk, src/txmempool.cpp:868-897), never the parent alone.
  • Admission freed only the incoming transaction's own size. A pool already over its limit, for example after a live -maxmempool decrease, stayed over, so every admission answered "mempool full" until the pool drained. Core's TrimToSize trims to the limit (src/txmempool.cpp:861-906). reload_policy swapped the config without trimming.
  • When the room still fell short after eviction, the "mempool full" return dropped the LeaveEvicted events for entries that were already gone.
  • When libbitcoinconsensus rejected an input, name_script_failure ran the native engine over the whole transaction, under the pool's write lock, only to find the error's name.

In the orphanage, the FIFO queue kept the txid of every orphan ever added: remove, expiry and per-peer eviction left theirs behind, and only a full orphanage popped stale ones off the front. oldest_for_peer scanned that queue on every add from a peer at its 50-orphan quota.

Fix

  • Order. The cheap, read-only full-pool refusal (the incoming feerate must beat the cheapest entry it could evict) stays ahead of the scripts, as Core's CheckFeeRate runs in PreChecks (src/validation.cpp:948). Then the scripts are checked, and only then is anything evicted.
  • Linear eviction. One pass builds a min-heap of the class, and each eviction pops it. Descendants come from the spends index (collect_children), and membership is a HashSet. The ranking (lowest modified feerate, descendants removed with their ancestor, the same rolling-minimum update) is unchanged; ties now break by txid instead of hash-map order.
  • Ancestors stay. evict_lowest_fee_entries_except never chooses an entry in a keep set; admission passes the incoming transaction's in-pool ancestors. The ancestor set is closed upwards, so nothing kept is a descendant of an evicted entry. The feerate bar is the cheapest entry the transaction may actually evict, and when there is none it is refused at once.
  • Trim to the limit. Admission evicts class_bytes + tx_size - class_budget, the overage with the transaction in the pool, as TrimToSize does. reload_policy trims both classes to their new limits right away and reports acting-class evictions as LeaveEvicted { full_pool }. As at admission, quarantine-class evictions emit nothing on the standard stream.
  • Reported refusals. The "mempool full" return after eviction emits LeaveEvicted for what it evicted.
  • Naming off the lock, one input. ScriptVerifier gains verify_transaction_unnamed and name_failure. Their default implementations keep today's behaviour for every other verifier. ConsensusVerifier's unnamed verify is libconsensus alone, and a failure carries the coarse ERR_SCRIPT. name_failure runs the native engine over the failing input only. ShadowVerifier passes both to its primary. Mempool admission and the submitpackage fee-bypass path verify under the lock and name the failure after releasing it. Block connection and testmempoolaccept are unchanged (testmempoolaccept already verified after releasing the lock). The verdict never changes, and the reason string is the same as before.
  • Orphanage. The queue is a BTreeMap keyed by an arrival sequence number, as Core keys its orphan announcements (m_entry_sequence, src/node/txorphanage.cpp:33-49). Removal deletes the orphan's key, so the queue holds exactly the live orphans. oldest_for_peer takes the minimum over that peer's own orphans, at most max_per_peer of them.
  • The manual's live-reload table now says a lower maxmempool trims the pool at once.

Tests

All new tests are in new files: node/src/mempool/pool_fullpool_tests.rs, node/src/mempool/orphanage_order_tests.rs and node/src/validation/script_naming_tests.rs. Each was run against the unfixed code first. After the fix, each guard was reverted on its own and the named test failed again.

Test Fails without the fix
a_script_failure_on_a_full_pool_evicts_nothing (with the must-succeed half: the same transaction with valid scripts evicts exactly one entry) yes: "a transaction whose scripts fail evicted entries" (2 of 3 entries left)
a_child_keeps_its_parent_in_a_full_pool yes: "the child's own parent was evicted to make room for it"
a_child_that_outbids_only_its_parent_is_refused yes: admitted instead of MempoolFull (it had evicted its parent)
a_full_pool_refusal_reports_its_evictions yes: admitted on the old code; with only the event emission removed, "an eviction was never reported" ([] vs the evicted txid)
lowering_maxmempool_trims_the_pool_at_once yes: "328 bytes over a 164-byte limit"
an_admission_trims_an_over_limit_pool yes: MempoolFull
eviction_cost_follows_what_it_evicts (20,000 entries, evict 600, also checks that the 600 lowest feerates go) yes: "evicting 600 of 20000 took 40.19s" (bound 2 s; the fix takes milliseconds)
a_script_failure_is_named_after_the_pool_is_released yes: reason stayed ERR_SCRIPT; naming before the release gives "the failure was named under the pool's write lock"
naming_runs_only_the_failing_input yes: ERR_SCRIPT instead of OP_RETURN was encountered
the_unnamed_verify_leaves_the_name_for_later (cpp and rust-shadow) yes: named under the caller's lock (for both, and for rust-shadow alone when only the shadow delegation is reverted)
verify_transaction_names_the_failure regression guard (passes before and after)
evicting_a_parent_takes_every_descendant regression guard for the spends-index walk (a child spending two outputs, a grandchild; each evicted once, spends index and byte count follow)
a_peer_at_its_quota_does_not_grow_the_queue yes: queue 10000 vs 50
removed_and_expired_orphans_leave_the_queue yes: 20 vs 10
a_returning_orphan_queues_as_the_newest yes: queue 3 vs 2 (and the returning orphan was evicted in place of the oldest)
the_quota_evicts_the_peers_own_oldest yes: queue 5 vs 4

acting_eviction_counts_only_same_class_bytes_toward_goal was edited in place: its hand-built entries now index their spends, as every admission path does, because eviction finds descendants through that index.

Local gate: cargo clippy --all-targets --all-features -D warnings, cargo clippy -p node --no-default-features --all-targets -D warnings, cargo test -p node --lib, cargo test -p satd --test regtest (mempool, orphan, sendraw, testmempool, package, rbf, replace and sighup filters) and cargo test -p satd --test policy. On one run of the policy target, layer_c_allow_own_submission_but_quarantine_when_gossiped got bad-txns-inputs-missingorspent from sendrawtransaction while all 22 node pairs were running in parallel. It passed 3 of 3 times on its own, and the whole target passed on a rerun. Input resolution is not touched here.

Notes

  • Not changed: eviction still ranks by each entry's own modified feerate, where Core ranks by chunk feerate (descendant score before cluster mempool). So a parent whose fee is carried by a child already in the pool can still go ahead of lower-paying entries. This is a larger change and is left for later.
  • Not changed: the feerate bar compares the incoming transaction's modified feerate with residents' admission-time feerate, and an RBF replacement's conflicts count toward the room it needs, although they leave afterwards.
  • Naming still runs the native engine once, over the failing input, on the caller's task. The native engine's per-signature annex and SIGHASH_SINGLE output hashing is separate later work.
  • Was stacked on Count sigops exactly as Bitcoin Core does #937, which is merged; release/0.6 is merged into this branch.

Release notes

When the mempool is full, an incoming transaction's scripts are now checked before anything is evicted to make room for it, and eviction never removes the incoming transaction's own parent. Eviction now costs time in proportion to what it removes rather than to the size of the pool, so admitting into a full pool no longer holds the mempool lock for long. Lowering maxmempool with a config reload now trims the pool to the new size at once; before, the node refused new transactions until the pool drained below the new limit. The orphan pool's internal queue no longer grows with every orphan ever received.

🤖 Generated with Claude Code

bkeroack and others added 2 commits October 7, 2026 16:50
satd counted sigops with rust-bitcoin 0.32's `Script::count_sigops` and
`Transaction::total_sigop_cost`. In accurate mode those keep the last
OP_1..OP_16 across later OP_CHECKSIG and OP_CHECKMULTISIG opcodes, where
Core's `GetSigOpCount` reads the opcode immediately before each multisig
opcode (src/script/script.cpp). An OP_CHECKMULTISIG that followed another
signature opcode took the earlier number instead of 20, so P2SH redeem
scripts and P2WSH witness scripts could count lower than Core counts them
(`OP_1 OP_CHECKSIG OP_CHECKMULTISIG`: 2 instead of 21). This is
rust-bitcoin issue #6368; no released rust-bitcoin version has the fix.

Add `validation::sigops`, a port of Core's `GetSigOpCount(fAccurate)`,
`GetSigOpCount(scriptSig)`, `CountWitnessSigOps`, `GetLegacySigOpCount`,
`GetP2SHSigOpCount` and `GetTransactionSigOpCost`, with Core's script
parsing, and Core's sigopcount_tests, script_p2sh_tests and
transaction_tests vectors. Use it for the block limit in `connect_block`
and `check_block`, the cost each mempool entry records (which the block
template budgets by and getblocktemplate reports), the policy engine's
`tx.sigops_cost`, and the Stratum coinbase bound.

The submitpackage fee-bypass path recorded a sigop cost of 0 and no
prevout metadata for the parents it admits, so the template counted them
as free. It now records what `accept_transaction` records, through one
shared helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A transaction arriving at a full pool evicted entries to make room for
itself before its scripts were checked. Core checks the scripts
(PolicyScriptChecks, ConsensusScriptChecks) before it adds the
transaction and trims the pool (src/validation.cpp,
AcceptSingleTransactionInternal), so a transaction whose scripts fail
changes nothing. satd now does the same: the cheap fee-rate refusal
stays ahead of the scripts, and eviction comes after them.

Eviction itself rescanned the whole pool for every evicted entry and
every descendant, checking each candidate against a Vec of what was
already chosen. It now pops a heap built in one pass, finds descendants
through the spends index, and tracks membership in a HashSet.

Around the same path:
- Eviction never takes the incoming transaction's own ancestors, which
  left the child in the pool spending an output nothing provided. The
  fee-rate bar is the cheapest entry it may actually evict.
- Admission trims the pool down to its limit, as Core's TrimToSize does,
  instead of freeing only its own size. A lowered -maxmempool trims the
  pool at once on reload; before, every admission was refused until the
  pool drained.
- A refusal after eviction reports the entries it evicted.
- The specific script error behind a libbitcoinconsensus rejection is
  found by running the native engine over the failing input only, after
  the pool's write lock is released.
- The orphanage's FIFO queue holds each live orphan once, keyed by an
  arrival sequence as Core's orphanage is, instead of keeping the txid of
  every orphan it ever held.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Eviction removes a held descendant of an evicted acting entry with it.
That entry never emitted an Enter, so a LeaveEvicted for it is a Leave
for a txid the standard stream never showed. The eviction now returns
each entry's scope, and admission (success and refusal) and the
-maxmempool reload trim emit LeaveEvicted only for entries that were
acting.

The test insert helpers that take a caller's transaction now index its
spends, as every admission does, since eviction finds descendants
through that index.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@bkeroack
bkeroack changed the base branch from fix/sigop-count-core-parity to release/0.6 October 8, 2026 18:11
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@bkeroack
bkeroack merged commit 97298b0 into release/0.6 Oct 14, 2026
36 checks passed
@bkeroack
bkeroack deleted the fix/mempool-full-pool-eviction branch October 14, 2026 05:35
bkeroack added a commit that referenced this pull request Oct 15, 2026
* Count sigops exactly as Bitcoin Core does

satd counted sigops with rust-bitcoin 0.32's `Script::count_sigops` and
`Transaction::total_sigop_cost`. In accurate mode those keep the last
OP_1..OP_16 across later OP_CHECKSIG and OP_CHECKMULTISIG opcodes, where
Core's `GetSigOpCount` reads the opcode immediately before each multisig
opcode (src/script/script.cpp). An OP_CHECKMULTISIG that followed another
signature opcode took the earlier number instead of 20, so P2SH redeem
scripts and P2WSH witness scripts could count lower than Core counts them
(`OP_1 OP_CHECKSIG OP_CHECKMULTISIG`: 2 instead of 21). This is
rust-bitcoin issue #6368; no released rust-bitcoin version has the fix.

Add `validation::sigops`, a port of Core's `GetSigOpCount(fAccurate)`,
`GetSigOpCount(scriptSig)`, `CountWitnessSigOps`, `GetLegacySigOpCount`,
`GetP2SHSigOpCount` and `GetTransactionSigOpCost`, with Core's script
parsing, and Core's sigopcount_tests, script_p2sh_tests and
transaction_tests vectors. Use it for the block limit in `connect_block`
and `check_block`, the cost each mempool entry records (which the block
template budgets by and getblocktemplate reports), the policy engine's
`tx.sigops_cost`, and the Stratum coinbase bound.

The submitpackage fee-bypass path recorded a sigop cost of 0 and no
prevout metadata for the parents it admits, so the template counted them
as free. It now records what `accept_transaction` records, through one
shared helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* mempool: verify scripts before evicting, and make eviction linear

A transaction arriving at a full pool evicted entries to make room for
itself before its scripts were checked. Core checks the scripts
(PolicyScriptChecks, ConsensusScriptChecks) before it adds the
transaction and trims the pool (src/validation.cpp,
AcceptSingleTransactionInternal), so a transaction whose scripts fail
changes nothing. satd now does the same: the cheap fee-rate refusal
stays ahead of the scripts, and eviction comes after them.

Eviction itself rescanned the whole pool for every evicted entry and
every descendant, checking each candidate against a Vec of what was
already chosen. It now pops a heap built in one pass, finds descendants
through the spends index, and tracks membership in a HashSet.

Around the same path:
- Eviction never takes the incoming transaction's own ancestors, which
  left the child in the pool spending an output nothing provided. The
  fee-rate bar is the cheapest entry it may actually evict.
- Admission trims the pool down to its limit, as Core's TrimToSize does,
  instead of freeing only its own size. A lowered -maxmempool trims the
  pool at once on reload; before, every admission was refused until the
  pool drained.
- A refusal after eviction reports the entries it evicted.
- The specific script error behind a libbitcoinconsensus rejection is
  found by running the native engine over the failing input only, after
  the pool's write lock is released.
- The orphanage's FIFO queue holds each live orphan once, keyed by an
  arrival sequence as Core's orphanage is, instead of keeping the txid of
  every orphan it ever held.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* mempool: report only acting entries evicted; test helpers index spends

Eviction removes a held descendant of an evicted acting entry with it.
That entry never emitted an Enter, so a LeaveEvicted for it is a Leave
for a txid the standard stream never showed. The eviction now returns
each entry's scope, and admission (success and refusal) and the
-maxmempool reload trim emit LeaveEvicted only for entries that were
acting.

The test insert helpers that take a caller's transaction now index its
spends, as every admission does, since eviction finds descendants
through that index.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 97298b0)
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