Repository navigation
mempool RPCs: match Bitcoin Core's getmempoolentry, getmempoolinfo, gettxout and sendrawtransaction - #946
Merged
Conversation
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>
…into fix/mempool-rpc-core-parity
bkeroack
changed the base branch from
fix/mempool-full-pool-eviction
to
release/0.6
October 14, 2026 05:34
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>
This was referenced Oct 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.rsget_entry_verbose; CoreentryToJSON,rpc/mempool.cpp:508-569):vsizewasweight / 4, rounded down. Core'sGetVirtualTransactionSizerounds up (policy/policy.cpp:395-398), so most segwit transactions read one vbyte low, andtestmempoolacceptand Esplora (which round up) disagreed with it.ancestorsize/descendantsizesummed the rounded-down values.heightwas hard-coded to 0. Core reports the tip height when the transaction entered (entryHeight).dependslisted every in-mempool ancestor. Core lists only the direct in-mempool parents, once each, in string order (rpc/mempool.cpp:536-549).bip125-replaceablelooked only at the transaction's own inputs. Core'sIsRBFOptInalso reports a transaction whose in-mempool ancestor signals (policy/rbf.cpp:24-50).fees.ancestor,fees.descendant,ancestorfees,descendantfees) summed base fees. Core sums modified fees (CalculateAncestorData,txmempool.cpp:913-941).getmempoolinfo(rawtx.rs; CoreMempoolInfoToJSON,rpc/mempool.cpp:1043-1065):total_feewas missing;bytesandusagewere both the serialized size (Core: the sum of virtual sizes, and memory use);loadedwas alwaystrue, including whilemempool.datwas still loading after RPC came up (Core:GetLoadTried(), set after the load attempt,init.cpp:2046-2048).sendrawtransaction(server.rs):maxfeerateandmaxburnamountwere 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 withAmountFromValue/ParseFeeRate(rpc/util.cpp:98-115), as satd'stestmempoolacceptandsubmitpackagealready do.maxfeeratecheck was skipped. With a policy ruleset loaded, the submission can admit what the dry run refused (anallowrule forgiving a standardness failure, orallowquarantined), so such a transaction was admitted without the fee cap.-27) was decided by the txindex (get_tx_location), which only answers with-txindexon. Core checks whether any of the transaction's outputs is still a coin (node/transaction.cpp:53-61). Without-txindex, satd answered-25for a confirmed transaction with an unspent output; with it,-27for one whose outputs were all spent, where Core answers missing inputs.gettxout(server.rs,blockchain.rs) ignoredinclude_mempool(Core defaulttrue,rpc/blockchain.cpp:1226-1242): a coin spent by a mempool transaction was reported unspent, and a mempool transaction's outputs came backnull.remove_for_block(pool.rs):removeConflictsremoves the conflict withremoveRecursive(txmempool.cpp:388-403). On a connect,accept_blocklater runsremove_for_reorg, which drops them, but the stored-tail drain path calls onlyremove_for_block, so the stranded children stayed in the pool (relayed and counted) until the nextaccept_block.prioritisetransactiondeltas were never cleared on confirmation, sogetprioritisedtransactionslisted 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; CoreLoadMempool,node/mempool_persist.cpp:43-139):getmempoolentry.timeand the-mempoolexpiryclock restarted on every restart, and records that had already expired were re-admitted. Core passes the persisted time toAcceptToMemoryPooland skips records past expiry (:103).prioritisetransactiondelta was applied after admission, so a transaction that met the relay floor only through its delta was dropped. Core applies it first (:101).remove_expired, which walked the whole pool under the write lock, so loading N transactions cost O(N²). Core'sExpirewalks its entry-time index from the oldest.Docs. The manual said
-limitclustercountbounds a transaction's whole cluster, but satd counts in-mempool ancestors only (expand_ancestors);max_descendant_countis never read.-maxmempoolis measured by serialized size, not memory, which was not documented.CORE_DIFFERENCES.mdsaidsubmitpackagerefusesmaxfeerate/maxburnamount, but it applies both.Fix
MempoolEntryrecordsheight(the tip at admission).get_entry_verboseandgetmempoolsummary(whose rows mirror it) use rounded-up vsize, signed modified fees in the rollups (as Core'sm_modified_fee; a negativeprioritisetransactiondelta lowers them, andfees.modifiedcan print negative, asValueFromAmountformats it), direct parents fordepends, and ancestor signalling forbip125-replaceable.MempoolInfogainsvsize,total_feeandusage;getmempoolinforeports them asbytes,total_feeandusage.MempoolInfo.bytes(serialized size) is unchanged, so-maxmempool,satd_mempool_bytesand the status page keep their meaning.usageis an estimate added up from what each entry holds;CORE_DIFFERENCES.mdsays so.Mempoolkeeps Core's load-tried flag;satdsets it after the startup load step, whether or not-persistmempoolis on;getmempoolinfo.loadedreports it.sendrawtransactionparses its limits withparse_btc_amount_arg/parse_fee_rate_arg, in Core's order (burn limit, decode, burn check, fee rate). The pre-submit checks move intorawtx::send_raw_transaction_preflight:-27from the UTXO set (shared withtestmempoolaccept), 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.gettxouttakesinclude_mempool(defaulttrue): an output an acting mempool transaction spends isnull, and an acting mempool transaction's outputs are reported withconfirmations: 0, as Core'sCCoinsViewMemPooldoes. 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 untilremove_for_blockruns, a pool transaction counts only while every coin it and its in-pool ancestors spend is still a coin at that tip.remove_for_blockremoves each conflict's descendants (asLeaveEvicted { BlockConflict }) and clears the deltas Core clears.load_mempoolapplies the delta first, skips expired records (counted in a newLoadStats.expired), and admits the rest with their persisted time (Mempool::accept_persisted_transaction).remove_expiredkeeps 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.configuration.mdandconfig-reference.mddescribe-limitclustercountas the ancestor limit it is, and-maxmempoolas serialized size; the config-reference note no longer listslimitclustercountamong unrecognized keys.CORE_DIFFERENCES.mddrops thesubmitpackagerow, updates thesavemempoolentry, 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 topersist.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 (vsize83, expected 84; withgetmempoolsummary.bytesreverted alone: 174 serialized bytes, expected the vsize sum).entry_height_is_the_tip_when_it_entered— yes (height0, expected 3).entry_depends_names_direct_parents_only— yes (dependslisted both ancestors).entry_bip125_replaceable_follows_an_ancestor_that_signals— yes (child reportedfalse).entry_fee_rollups_use_modified_fees— yes (fees.ancestor3000, expected 8000; withtotal_feesumming 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 viewgettxoutuses.info_usage_grows_with_the_pool— yes (usage0).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 (accepted0).load_decodes_records_but_skips_unfundednow 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 (loadedtrue on a pool that never loaded).rawtx::tests::test_getmempoolinfo_emptynow expectsfalsefor the same reason.getmempoolinfo_bytes_is_vsize_and_total_fee_is_base_fees— yes (bytes83, 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— themaxfeerate/maxburnamountrefusals, thegetmempoolentry/getmempoolinfofields,gettxoutwith and without the mempool, and-27for a confirmed transaction on a node without-txindexFails without the fix: yes, for each piece of handler wiring reverted on its own: the oldmaxfeerateparser (sendrawtransaction <hex> -1was accepted and returned the txid), the txindex-based-27(-25 bad-txns-inputs-missingorspentinstead of-27), andgettxoutignoringinclude_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 forgetmempoolentry,getmempoolinfo,getrawmempool,gettxout,sendrawtransaction,testmempoolaccept,savemempool,prioritisetransaction, the default-units tests, the SIGHUP mempool-policy reload and the Esplora mempool views.Notes
vsizeisceil(weight / 4). Core's is also sigop-adjusted (-bytespersigop); satd accepts that flag and ignores it everywhere, which is a separate change.remove_for_reorgstill runs on every connect inaccept_block(a full-pool coin scan). Running it only after a reorg would touchchain/state.rs; left for a separate change.prioritisetransactiondeltas for transactions not in the pool are still not persisted (that needs amempool.datformat change).savemempooldoes not refuse before the startup load finishes, as Core does withGetLoadTried(). Wiring it would make existing regtest tests that callsavemempoolright after startup race the load.CORE_DIFFERENCES.mdsays so.maxfeeratecomparison (floor(fee * 1000 / vsize) > rate) is the onetestmempoolacceptalready uses. Core comparesfee > ceil(rate * vsize / 1000), which can differ by one satoshi at the boundary.-limitclustercountdoes.gettxoutnow consults the mempool by default;sendrawtransactionnow refuses a negative, unreadable or ≥ 1 BTC/kvBmaxfeerate/maxburnamount(astestmempoolacceptandsubmitpackagealready did) instead of accepting the transaction with the limit dropped;getmempoolinfo.bytesis now total vsize andloadedis false until the startup load finishes.satd_mempool_bytesand the-maxmempoolbudget still count serialized bytes.Release notes
REL-
vsizeisceil(weight / 4). Core's is also sigop-adjusted (-bytespersigop); satd accepts that flag and ignores it everywhere, which is a separate change.remove_for_reorgstill runs on every connect inaccept_block(a full-pool coin scan). Running it only after a reorg would touchchain/state.rs; left for a separate change.prioritisetransactiondeltas for transactions not in the pool are still not persisted (that needs amempool.datformat change).savemempooldoes not refuse before the startup load finishes, as Core does withGetLoadTried(). Wiring it would make existing regtest tests that callsavemempoolright after startup race the load.CORE_DIFFERENCES.mdsays so.maxfeeratecomparison (floor(fee * 1000 / vsize) > rate) is the onetestmempoolacceptalready uses. Core comparesfee > ceil(rate * vsize / 1000), which can differ by one satoshi at the boundary.-limitclustercountdoes.gettxoutnow consults the mempool by default;sendrawtransactionnow refuses a negative, unreadable or ≥ 1 BTC/kvBmaxfeerate/maxburnamount(astestmempoolacceptandsubmitpackagealready did) instead of accepting the transaction with the limit dropped;getmempoolinfo.bytesis now total vsize andloadedis false until the startup load finishes.satd_mempool_bytesand the-maxmempoolbudget still count serialized bytes.🤖 Generated with Claude Code