Skip to content

mempool RPCs: match Bitcoin Core's getmempoolentry, getmempoolinfo, gettxout and sendrawtransaction - #946

Merged
bkeroack merged 8 commits into
release/0.6from
fix/mempool-rpc-core-parity
Oct 14, 2026
Merged

bkeroack merged 8 commits into
release/0.6from
fix/mempool-rpc-core-parity

Conversation

@bkeroack

@bkeroack bkeroack commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Based on release/0.6; #937 and #942 have merged, and this branch includes them.

Problem

Several mempool RPCs answered differently from Bitcoin Core, and the mempool reload and block paths dropped state Core keeps. Core citations are to src/ at 16613c9d.

getmempoolentry / getrawmempool verbose (pool.rs get_entry_verbose; Core entryToJSON, rpc/mempool.cpp:508-569):

  • vsize was weight / 4, rounded down. Core's GetVirtualTransactionSize rounds up (policy/policy.cpp:395-398), so most segwit transactions read one vbyte low, and testmempoolaccept and Esplora (which round up) disagreed with it. ancestorsize / descendantsize summed the rounded-down values.
  • height was hard-coded to 0. Core reports the tip height when the transaction entered (entryHeight).
  • depends listed every in-mempool ancestor. Core lists only the direct in-mempool parents, once each, in string order (rpc/mempool.cpp:536-549).
  • bip125-replaceable looked only at the transaction's own inputs. Core's IsRBFOptIn also reports a transaction whose in-mempool ancestor signals (policy/rbf.cpp:24-50).
  • The ancestor and descendant fee rollups (fees.ancestor, fees.descendant, ancestorfees, descendantfees) summed base fees. Core sums modified fees (CalculateAncestorData, txmempool.cpp:913-941).

getmempoolinfo (rawtx.rs; Core MempoolInfoToJSON, rpc/mempool.cpp:1043-1065): total_fee was missing; bytes and usage were both the serialized size (Core: the sum of virtual sizes, and memory use); loaded was always true, including while mempool.dat was still loading after RPC came up (Core: GetLoadTried(), set after the load attempt, init.cpp:2046-2048).

sendrawtransaction (server.rs):

  • maxfeerate and maxburnamount were parsed by hand. A negative value became 0, which disabled the check; text it could not parse became the default; a rate of 1 BTC/kvB or more was accepted. Core reads both with AmountFromValue / ParseFeeRate (rpc/util.cpp:98-115), as satd's testmempoolaccept and submitpackage already do.
  • When the pre-flight dry run refused the transaction for any reason other than "already in the mempool", the maxfeerate check was skipped. With a policy ruleset loaded, the submission can admit what the dry run refused (an allow rule forgiving a standardness failure, or allowquarantined), so such a transaction was admitted without the fee cap.
  • "Already confirmed" (-27) was decided by the txindex (get_tx_location), which only answers with -txindex on. Core checks whether any of the transaction's outputs is still a coin (node/transaction.cpp:53-61). Without -txindex, satd answered -25 for a confirmed transaction with an unspent output; with it, -27 for one whose outputs were all spent, where Core answers missing inputs.

gettxout (server.rs, blockchain.rs) ignored include_mempool (Core default true, rpc/blockchain.cpp:1226-1242): a coin spent by a mempool transaction was reported unspent, and a mempool transaction's outputs came back null.

remove_for_block (pool.rs):

  • A transaction that conflicts with the block was removed, but its descendants stayed. Core's removeConflicts removes the conflict with removeRecursive (txmempool.cpp:388-403). On a connect, accept_block later runs remove_for_reorg, which drops them, but the stored-tail drain path calls only remove_for_block, so the stranded children stayed in the pool (relayed and counted) until the next accept_block.
  • prioritisetransaction deltas were never cleared on confirmation, so getprioritisedtransactions listed mined transactions for good and a transaction reorged back in got its old delta again. Core clears the delta of each block transaction and of each direct conflict (txmempool.cpp:398, 420).

Mempool reload (persist.rs; Core LoadMempool, node/mempool_persist.cpp:43-139):

  • The persisted entry time was used only for ordering; every re-admitted transaction got the current time, so getmempoolentry.time and the -mempoolexpiry clock restarted on every restart, and records that had already expired were re-admitted. Core passes the persisted time to AcceptToMemoryPool and skips records past expiry (:103).
  • The persisted prioritisetransaction delta was applied after admission, so a transaction that met the relay floor only through its delta was dropped. Core applies it first (:101).
  • Every admission ran remove_expired, which walked the whole pool under the write lock, so loading N transactions cost O(N²). Core's Expire walks its entry-time index from the oldest.

Docs. The manual said -limitclustercount bounds a transaction's whole cluster, but satd counts in-mempool ancestors only (expand_ancestors); max_descendant_count is never read. -maxmempool is measured by serialized size, not memory, which was not documented. CORE_DIFFERENCES.md said submitpackage refuses maxfeerate / maxburnamount, but it applies both.

Fix

  • MempoolEntry records height (the tip at admission). get_entry_verbose and getmempoolsummary (whose rows mirror it) use rounded-up vsize, signed modified fees in the rollups (as Core's m_modified_fee; a negative prioritisetransaction delta lowers them, and fees.modified can print negative, as ValueFromAmount formats it), direct parents for depends, and ancestor signalling for bip125-replaceable.
  • MempoolInfo gains vsize, total_fee and usage; getmempoolinfo reports them as bytes, total_fee and usage. MempoolInfo.bytes (serialized size) is unchanged, so -maxmempool, satd_mempool_bytes and the status page keep their meaning. usage is an estimate added up from what each entry holds; CORE_DIFFERENCES.md says so.
  • Mempool keeps Core's load-tried flag; satd sets it after the startup load step, whether or not -persistmempool is on; getmempoolinfo.loaded reports it.
  • sendrawtransaction parses its limits with parse_btc_amount_arg / parse_fee_rate_arg, in Core's order (burn limit, decode, burn check, fee rate). The pre-submit checks move into rawtx::send_raw_transaction_preflight: -27 from the UTXO set (shared with testmempoolaccept), then the fee cap. When the dry run refuses and a ruleset is loaded, the fee is worked out from the inputs so the cap still applies. Without a ruleset the submission refuses whatever the dry run refused and reports why, so nothing changes there.
  • gettxout takes include_mempool (default true): an output an acting mempool transaction spends is null, and an acting mempool transaction's outputs are reported with confirmations: 0, as Core's CCoinsViewMemPool does. Quarantined transactions stay invisible, as on every standard surface. The pool is read in one lock acquisition (Mempool::acting_view_of) inside the same chain snapshot as the coin and the tip. Because the pool trails the chain until remove_for_block runs, a pool transaction counts only while every coin it and its in-pool ancestors spend is still a coin at that tip.
  • remove_for_block removes each conflict's descendants (as LeaveEvicted { BlockConflict }) and clears the deltas Core clears.
  • load_mempool applies the delta first, skips expired records (counted in a new LoadStats.expired), and admits the rest with their persisted time (Mempool::accept_persisted_transaction).
  • remove_expired keeps a lower bound on the entries' times (EntryMap::time_floor: lowered by every insert, reset by an in-place edit of a resident entry, raised to the exact minimum by each walk), and walks the pool only when something can have expired. An admission no longer costs a walk over the pool, and the reload is linear.
  • Docs: configuration.md and config-reference.md describe -limitclustercount as the ancestor limit it is, and -maxmempool as serialized size; the config-reference note no longer lists limitclustercount among unrecognized keys. CORE_DIFFERENCES.md drops the submitpackage row, updates the savemempool entry, and gains a "Mempool size and descendant limits" entry.

Tests

New files: node/src/mempool/pool_mempoolrpc_tests.rs, node/src/rpc/rawtx_mempoolrpc_tests.rs, satd/tests/mempool_rpc.rs (new test target); three tests added to persist.rs's test module.

Each named test was run against the code with its guard reverted (the old behaviour put back in place) and failed on the assertion noted; restored, it passes.

node/src/mempool/pool_mempoolrpc_tests.rs:

  • entry_vsize_rounds_up_as_core_does — fails without the fix: yes (vsize 83, expected 84; with getmempoolsummary.bytes reverted alone: 174 serialized bytes, expected the vsize sum).
  • entry_height_is_the_tip_when_it_entered — yes (height 0, expected 3).
  • entry_depends_names_direct_parents_only — yes (depends listed both ancestors).
  • entry_bip125_replaceable_follows_an_ancestor_that_signals — yes (child reported false).
  • entry_fee_rollups_use_modified_fees — yes (fees.ancestor 3000, expected 8000; with total_fee summing modified fees instead: 8000, expected 3000).
  • entry_fee_rollups_carry_a_negative_modified_fee: yes (with the modified fee clamped at zero, the rollups read 2000 instead of 1000).
  • prioritising_a_transaction_not_in_the_pool_keeps_the_expiry_floor: yes (with the floor reset on a missed lookup).
  • acting_view_reads_spend_and_creation_together: covers the single-lock pool view gettxout uses.
  • info_usage_grows_with_the_pool — yes (usage 0).
  • block_conflict_takes_the_conflicts_descendants — yes ("its child must leave the pool").
  • block_clears_the_priority_of_what_it_confirms — yes, for each of the two clears ("a confirmed transaction's delta is cleared"; "a block conflict's delta is cleared").
  • expiry_pass_skips_the_walk_until_something_can_have_expired — yes, for both halves (the floor check, and the reset on an in-place edit).

node/src/mempool/persist.rs (test module):

  • load_keeps_each_records_entry_time — yes (entry time was the load time).
  • load_skips_a_record_past_expiry — yes ((accepted, skipped, expired) was (2, 0, 0)).
  • load_applies_the_delta_before_admission — yes (accepted 0).
  • load_decodes_records_but_skips_unfunded now uses recent record times, so expiry is not what skips its records.

node/src/rpc/rawtx_mempoolrpc_tests.rs:

  • getmempoolinfo_loaded_waits_for_the_load — yes (loaded true on a pool that never loaded). rawtx::tests::test_getmempoolinfo_empty now expects false for the same reason.
  • getmempoolinfo_bytes_is_vsize_and_total_fee_is_base_fees — yes (bytes 83, expected 84).
  • gettxout_hides_an_output_spent_in_the_mempool — yes (the spent coin was reported).
  • gettxout_reports_a_mempool_output_with_no_confirmations — yes (null).
  • gettxout_does_not_see_a_quarantined_transaction — yes (a held transaction's output was reported).
  • preflight_finds_a_confirmed_transaction_in_the_utxo_set — yes (Ok(()), expected -27).
  • preflight_caps_the_fee_rate — passes with and without (the existing cap, kept as a regression test).
  • gettxout_trusts_the_pool_only_while_the_coins_it_spends_remain: yes (with the coin check removed, an output the tip had already spent was reported unspent).
  • a_negative_amount_formats_as_core_does.
  • preflight_caps_the_fee_of_a_transaction_only_a_policy_admits — yes (Ok(()), expected -25); paired with the same transaction going through uncapped and being admitted.

satd/tests/mempool_rpc.rs (live regtest node):

  • mempool_rpcs_match_core — the maxfeerate/maxburnamount refusals, the getmempoolentry/getmempoolinfo fields, gettxout with and without the mempool, and -27 for a confirmed transaction on a node without -txindex Fails without the fix: yes, for each piece of handler wiring reverted on its own: the old maxfeerate parser (sendrawtransaction <hex> -1 was accepted and returned the txid), the txindex-based -27 (-25 bad-txns-inputs-missingorspent instead of -27), and gettxout ignoring include_mempool (the coinbase the mempool spends was reported).
  • sendrawtransaction_of_a_spent_confirmed_transaction_is_missing_inputs — with -txindex=1, a confirmed transaction whose outputs are all spent is answered as missing inputs (-25), not -27. Fails without the fix: yes (-27).

Also run locally: 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-mcp, cargo test -p satd --test policy, and the regtest tests for getmempoolentry, getmempoolinfo, getrawmempool, gettxout, sendrawtransaction, testmempoolaccept, savemempool, prioritisetransaction, the default-units tests, the SIGHUP mempool-policy reload and the Esplora mempool views.

Notes

  • vsize is ceil(weight / 4). Core's is also sigop-adjusted (-bytespersigop); satd accepts that flag and ignores it everywhere, which is a separate change.
  • Kept: remove_for_reorg still runs on every connect in accept_block (a full-pool coin scan). Running it only after a reorg would touch chain/state.rs; left for a separate change.
  • Kept: prioritisetransaction deltas for transactions not in the pool are still not persisted (that needs a mempool.dat format change).
  • Kept: savemempool does not refuse before the startup load finishes, as Core does with GetLoadTried(). Wiring it would make existing regtest tests that call savemempool right after startup race the load. CORE_DIFFERENCES.md says so.
  • Kept: the maxfeerate comparison (floor(fee * 1000 / vsize) > rate) is the one testmempoolaccept already uses. Core compares fee > ceil(rate * vsize / 1000), which can differ by one satoshi at the boundary.
  • Kept: no descendant or cluster limit is added; only the docs change to say what -limitclustercount does.
  • Behaviour changes callers can see, all to Core's answer: gettxout now consults the mempool by default; sendrawtransaction now refuses a negative, unreadable or ≥ 1 BTC/kvB maxfeerate/maxburnamount (as testmempoolaccept and submitpackage already did) instead of accepting the transaction with the limit dropped; getmempoolinfo.bytes is now total vsize and loaded is false until the startup load finishes. satd_mempool_bytes and the -maxmempool budget still count serialized bytes.

Release notes

REL- vsize is ceil(weight / 4). Core's is also sigop-adjusted (-bytespersigop); satd accepts that flag and ignores it everywhere, which is a separate change.

  • Kept: remove_for_reorg still runs on every connect in accept_block (a full-pool coin scan). Running it only after a reorg would touch chain/state.rs; left for a separate change.
  • Kept: prioritisetransaction deltas for transactions not in the pool are still not persisted (that needs a mempool.dat format change).
  • Kept: savemempool does not refuse before the startup load finishes, as Core does with GetLoadTried(). Wiring it would make existing regtest tests that call savemempool right after startup race the load. CORE_DIFFERENCES.md says so.
  • Kept: the maxfeerate comparison (floor(fee * 1000 / vsize) > rate) is the one testmempoolaccept already uses. Core compares fee > ceil(rate * vsize / 1000), which can differ by one satoshi at the boundary.
  • Kept: no descendant or cluster limit is added; only the docs change to say what -limitclustercount does.
  • Behaviour changes callers can see, all to Core's answer: gettxout now consults the mempool by default; sendrawtransaction now refuses a negative, unreadable or ≥ 1 BTC/kvB maxfeerate/maxburnamount (as testmempoolaccept and submitpackage already did) instead of accepting the transaction with the limit dropped; getmempoolinfo.bytes is now total vsize and loaded is false until the startup load finishes. satd_mempool_bytes and the -maxmempool budget still count serialized bytes.

🤖 Generated with Claude Code

bkeroack and others added 5 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>
…ransaction answers

getmempoolentry / getrawmempool verbose: vsize rounds up as Core's
GetVirtualTransactionSize does; height is the tip at admission; depends
lists direct in-mempool parents only; bip125-replaceable follows an
ancestor that signals (IsRBFOptIn); the ancestor and descendant fee
rollups sum modified fees. getmempoolsummary rows follow.

getmempoolinfo: bytes is the sum of virtual sizes, usage an estimate of
the entries' memory, total_fee the base fees, and loaded reports whether
the startup load of mempool.dat has finished.

sendrawtransaction reads maxfeerate and maxburnamount as Core does
(AmountFromValue / ParseFeeRate): a negative, unreadable or >= 1 BTC/kvB
value is an error instead of a silently weakened limit. -27 is decided
from the UTXO set, not the txindex. With a policy ruleset loaded, a
transaction the dry run refused but the submission would admit is still
held to maxfeerate.

gettxout honours include_mempool (default true): an output a mempool
transaction spends is null, and a mempool transaction's outputs are
reported with zero confirmations.

remove_for_block removes the descendants of a block conflict and clears
the prioritisation of confirmed transactions and direct conflicts, as
Core's removeForBlock / removeConflicts do.

Mempool reload applies the persisted delta before admission, keeps each
record's entry time and skips expired records (Core's LoadMempool). The
expiry pass keeps a lower bound on entry times and walks the pool only
when something can have expired, so an admission no longer costs a walk
over the pool and the reload is linear.

Docs: -limitclustercount bounds ancestors only; -maxmempool counts
serialized bytes; submitpackage applies maxfeerate and maxburnamount.

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/mempool-full-pool-eviction to release/0.6 October 14, 2026 05:34
bkeroack and others added 3 commits October 13, 2026 23:35
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…egative modified fees

gettxout read a pool output and the chain tip separately, outside the
coherent snapshot. The pool trails the chain until remove_for_block
runs, so a block that confirmed a pool transaction and spent its output
could be named as bestblock beside that output reported unspent. The
pool reads now sit inside the snapshot, and a pool transaction counts
only while every coin it and its in-pool ancestors spend is still a coin
at the tip.

The ancestor and descendant fee rollups added each modified fee clamped
at zero. Core keeps modified fees signed (CTxMemPoolEntry::m_modified_fee)
and sums them as they are (CalculateAncestorData), so a negative
prioritisetransaction delta now lowers the totals, and fees.modified can
be negative, formatted as Core's ValueFromAmount does.

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

gettxout asked the pool whether the outpoint was spent and what output
created it under two separate read locks, so a policy reload that
promoted a parent and child between them could be read half before and
half after. Mempool::acting_view_of answers both under one lock.

EntryMap::get_mut reset the expiry time floor even when the entry was
absent. A mempool.dat reload prioritises each record before admitting
it, so every prioritised record forced the next admission to walk the
pool again. A miss now leaves the floor alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@bkeroack
bkeroack merged commit fbad3bd into release/0.6 Oct 14, 2026
36 checks passed
@bkeroack
bkeroack deleted the fix/mempool-rpc-core-parity branch October 14, 2026 12:11
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