Repository navigation
store: count body decodes at the decode site, bound the proposal parent memo, and probe spentness on the connected create - #968
Conversation
There was a problem hiding this comment.
Request changes
The store changes hold up. The decode counter sits on get_meta_and_outputs, fks_by_txid returns fks without unpacking the body, and has_confirmed_strong_spender / spenders_raw resolve the connected create. The two-row test (older connected row stamped, newer unconnected row empty) matches the spentness bug.
This branch forked before #967. On current master, proposal_connect returns (fees, prevouts) and check_block_proposal_with uses those TxOuts for the BIP141 sigop limit and verify_tx_scripts_with_flags. Fee sums use checked_add (bad-txns-inputvalues-outofrange, bad-txns-txouttotal-toolarge, bad-txns-fee-outofrange). This PR stores Amount only and still saturates. Merging onto current master conflicts in proposal_connect. Taking this side drops the prevouts the sigop and script checks require, and check_block_proposal_rejects_witness_sigops_over_the_limit would fail.
Rebase onto master. Keep one tip-only decode per distinct parent and the spent-vout bound, and keep script_pubkey on every output the check still holds (in-block creates and the spent parent vouts). Keep the checked adds.
put_spend still resolves with probe_body_match_fk (newest row, fence ignored). The readers fixed here use get_fk_by_txid_tip. The only callers are the store tests, so this does not affect consensus today. A later caller would stamp the row spentness no longer reads. The same tip resolve belongs on that helper while it stays public.
| let txids: Vec<Txid> = block.txdata.iter().map(|tx| tx.compute_txid()).collect(); | ||
| let parents = confirmed_parent_outputs(query, block, &txids); | ||
| let cb_txid = txids[0]; | ||
| let mut created: HashMap<OutPoint, Amount> = HashMap::new(); |
There was a problem hiding this comment.
On current master this map is TxOut, and proposal_connect returns those prevouts so the caller can count witness/P2SH sigops and run verify_tx_scripts_with_flags. Amount drops script_pubkey.
This also conflicts with #967. The other side of that conflict uses checked_add for the input, output, and fee totals (bad-txns-inputvalues-outofrange, bad-txns-txouttotal-toolarge, bad-txns-fee-outofrange); this side still saturates.
Rebase onto master. One tip-only decode per parent and the spent-vout bound can stay, with script_pubkey kept on each output this check still holds.
The two proposal-check counters sat on `Store` beside `utxo_view`, the word every mempool accept loads, and only saw `Store::get_tx`, `Store::get_tx_meta_and_outputs`, and the `resolve_txid` candidates counted by hand at that call site. `TxTable::get` reached from the spentness probe and `put_spend` was never counted, so the proposal pin `tx_gets == 0` held while the check decoded one body per input. Move the counter into `TxTable`, one Relaxed add where the packed `txout` bytes are decoded for an outs reader (`get_meta_and_outputs`), off `Store` and the `utxo_view` word. It lives in `TxTable` rather than on a `Store` wrapper because the spentness probe, `put_spend`, and the spender walks decode inside `TxTable` (`get_by_txid`), where a `Store` word cannot see them. `get_full` is the confirm write stage's decoder and stays uncounted. The confirm path reaches the counted decoder only through `Store::get_tx_meta_and_outputs` (the scripthash collect cold branch and tip disconnect) and `Store::get_tx`, the wrappers every other reader uses; on master those wrappers did this same one Relaxed add on `Store::tx_outs_decodes` and `tx_gets`, so IBD does the word it already did and gains none. That cold decode is already in the `ibd: perf` inventory as `sh_collect_cold`, so no timer is added. The other callers of `TxTable::get`, `get_by_txid` and the unstamped-row arm of `get_meta_and_prevouts`, serve tests, scripthash history, and `getblockstats`, not an IBD stage. `TxTable::get` becomes the outs decoder with the outs dropped: the whole-body decoder it used returned an empty ins vector for `txout`, so the record is the same and `Store::get_tx`'s doc no longer implies a cheaper decode. The store and proposal pins now read the real counts: `put_spend`, the spentness probe, each spender walk, and every proposal input decode the create body once. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`resolve_txid` went through `get_all_by_txid`, which decoded the packed body of every head candidate and then kept only the fks. `put_spend`, `has_confirmed_strong_spender_at`, and `spenders_raw` went through `get_by_txid` and discarded the record. Each `is_outpoint_spent` from mempool accept, gettxout, Electrum, and the block proposal check was one full body decode per input, and `get_fk_by_txid_tip` one per BIP30 candidate. `fks_by_txid` keeps the probe order and the `txid.body` verify and returns only the fks; it is in-crate, so it is `pub(crate)`. The three spend paths take `probe_body_match_fk` directly. The store pins flip to zero and the proposal pin reads one decode per distinct parent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The parent memo kept every decoded output of each distinct parent until the check returned, so a block spending one vout each of many fan-out parents held all of their outputs in RAM. It also resolved the parent with `TipThenAny`, which returns a row that exists only in a reorged-out block; that row was decoded before `coinbase_spend_is_immature` rejected it on the missing fence height. A pre-pass groups the block's spent vouts per parent (in-block creates excluded), resolves each parent on the connected chain only, decodes it once, and keeps just the spent vouts as `TxOut`s (value and script, which the script and sigop walk needs), O(block inputs). The connect loop still probes spentness per input and keeps its reject order; a parent that does not resolve contributes nothing, so its spends reject in block order as before. The RAM/CPU trade is on `proposal_connect`'s rustdoc rather than a comment at the map. The unconnected-prevout pin now also reads zero decodes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Six `check_block_proposal_*` tests booted six hubs, two of them to height 101 (12.7 s together), to tell one story about one node: a tip child passes, a fat coinbase is `bad-cb-amount` after the structure checks, an immature coinbase is rejected before fees, a mature spend is priced, a fan-out parent decodes once, and a reorged-out row is not an input. One journey on one 101-block hub now carries every beat (7.8 s); the mutating beats observe the earlier ones, so it stays one test rather than a shared fixture. The unconnected-prevout test was a twin of the invalidate beat and is folded into it. The fold also adds the pin the memo was missing: a parent first seen through its unspent vout 1 does not vouch for its confirmed-spent vout 0, so spentness stays a per-input probe. The rustdoc names the 101-block chain that regtest maturity forces, and that one boot, as the reason the test runs past the two-second budget, says why the read-only beats ride the same hub, and no longer claims that the SV2 template provider runs this check; its only caller is `getblocktemplate` proposal mode. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`has_confirmed_strong_spender_at` and `spenders_raw` resolved the create with `probe_body_match_fk`, the newest body match with the fence ignored, while the value side of the proposal check and of mempool accept resolves the same txid on the connected chain. With two Class A rows for one txid where the older row is connected and carries the spend stamp and the newer is not (a competing block at the same height, archived and then reorged away), `is_outpoint_spent` probed the newer row's empty slots and answered false, so a confirmed-spent output priced as spendable in `getblocktemplate` proposal mode, mempool accept, `gettxout`, and the Electrum and Esplora spent checks. Resolve the create with `get_fk_by_txid_tip` in both readers: head probe, `txid.body` verify, one fence read, no body decode. A strong spender always hangs off the connected create, so `spenders` and `spenders_at` lose nothing; a txid with no connected row has no best-chain spenders. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4cf000c to
8655524
Compare
There was a problem hiding this comment.
Approve
Re-reviewed 8655524 against current master. The earlier block is addressed: proposal_connect still returns prevouts, created and the parent memo hold TxOut (value and script), and the fee, subsidy, and output sums stay on checked_add. check_block_proposal_rejects_witness_sigops_over_the_limit is still in the tree. Required checks on this head are green.
Spentness is the change that matters. has_confirmed_strong_spender_at and spenders_raw now resolve through get_fk_by_txid_tip (head probe, txid.body verify, fence, no body decode). That is the same connected row get_tx_by_txid / TipThenAny already prefers for the value side of mempool accept and gettxout. A newer never-connected row no longer hides a spend stamped on the connected create. The store test plants that pair and checks both the probe and spenders. With no connected row, both readers return "not spent" / no spenders, which matches a coin that is not on the best chain. chain_prevout still drops a row with no fence height before it can price it.
The proposal check keeps that split. The pre-pass resolves each parent tip-only, decodes it once, and retains only the outputs this block spends, scripts included. chain_txout still asks is_outpoint_spent per input before it trusts the memo, so a hit on vout 1 does not vouch for a spent vout 0. Same-block creates still supply the prevout from the block itself. A parent that does not resolve is omitted, and the spend fails bad-txns-inputs-missingorspent in block order, including a reorged-out row, which is not decoded. Store read errors on that path become the same missing-input reject they already became. An in-block txid is not also looked up on the chain; a spend of that txid before the creating transaction is a missing input. That is the stricter result on a same-block replay of a confirmed txid.
TxTable::get and get_meta_and_outputs share one outs decode, which matches the old txout decoder (it never carried inputs). get_full stays off the counter. fks_by_txid preserves newest-first order and the body-txid check without unpacking outputs. lookup_tx_fk is the same TipThenAny result as before.
put_spend still resolves with probe_body_match_fk (newest row, fence ignored). Nothing in production calls it; confirm annotates with the create fk it already has. A later caller of put_spend in the two-row case would stamp the row these readers no longer look at.
The D11 journey ordered a SubmitSolution's NewTemplate before a 1200-input proposal's Success. That margin came from the per-input parent decode in the proposal check, which reardencode#961 and reardencode#968 removed: the validation now takes about 210 ms at 1200 inputs and the accept's tip write races it, so the journey failed 10 of 10 runs on the merged check (Success first, or inconclusive-not-best-prevblk when the tip moved before the check). Pin the contract on the frame that has no blocking work behind it: a RequestTransactionData sent behind a 400-input proposal is answered before that proposal's Success and in under a quarter of its wall. A SubmitSolution sent behind a third copy is accepted and pushes the solved tip's template; it is not ordered against that copy's reply, which is the straddle the plan already names (Success on the starting tip, or the proposal check's reject once the tip moved). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Plan D no longer stacks on sv2/plan-c-fee-push (merged as reardencode#949 and reardencode#951) and its D1 step is upstream: reardencode#959 moved the proposal check onto ChainHub, reardencode#963, reardencode#967, reardencode#968, and reardencode#969 made it complete, one decode per parent, bounded, and right about spent coins after a reorg. Name those as the prerequisites and why: the check is the job-validation hot path. Drop the two risks they closed (no script execution, reardencode#967; the per-input parent decode, reardencode#968), name both ends of the tip-change straddle the D11 journey now accepts, fix the "no scripts" CPU-trade lines, and remove the getblocktemplate bad-cb-amount changelog entry that reardencode#959 already carries. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The D11 journey ordered a SubmitSolution's NewTemplate before a 1200-input proposal's Success. That margin came from the per-input parent decode in the proposal check, which reardencode#961 and reardencode#968 removed: the validation now takes about 210 ms at 1200 inputs and the accept's tip write races it, so the journey failed 10 of 10 runs on the merged check (Success first, or inconclusive-not-best-prevblk when the tip moved before the check). Pin the contract on the frame that has no blocking work behind it: a RequestTransactionData sent behind a 400-input proposal is answered before that proposal's Success and in under a quarter of its wall. A SubmitSolution sent behind a third copy is accepted and pushes the solved tip's template; it is not ordered against that copy's reply, which is the straddle the plan already names (Success on the starting tip, or the proposal check's reject once the tip moved). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Plan D no longer stacks on sv2/plan-c-fee-push (merged as reardencode#949 and reardencode#951) and its D1 step is upstream: reardencode#959 moved the proposal check onto ChainHub, reardencode#963, reardencode#967, reardencode#968, and reardencode#969 made it complete, one decode per parent, bounded, and right about spent coins after a reorg. Name those as the prerequisites and why: the check is the job-validation hot path. Drop the two risks they closed (no script execution, reardencode#967; the per-input parent decode, reardencode#968), name both ends of the tip-change straddle the D11 journey now accepts, fix the "no scripts" CPU-trade lines, and remove the getblocktemplate bad-cb-amount changelog entry that reardencode#959 already carries. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Follow-up to #961 and #963. Five commits; each compiles and passes the store and net lib suites on its own. Rebased onto master after #967: the proposal check now also runs the script and sigop walk over the resolved prevouts, so the parent memo keeps the spent
TxOuts (value and script), not bare values.Why this matters
check_block_proposalis about to become a hot path. The Stratum V2 job-validation work (sv2-spec discussion #239; rbitcoin branchsv2/job-validation) has a Job Declarator Server ask this node "is this miner's template valid?" for every declared job, dozens of times a minute per pool, and the mempool'sget_coinruns the same parent-output read for every incoming transaction. Both need the read to be cheap (one decode per parent, not one per input), bounded in memory (proportional to the block, not to the parents' total outputs), and right about spent coins. The spentness fix in this stack is also a correctness improvement for every surface that reports whether a coin is spent (gettxout, mempool accept, Electrum, Esplora): after a reorg the spent marker and the spentness probe could consult different copies of the same transaction.Commits
Storecounters sat besideutxo_viewand only sawStore::get_tx/get_tx_meta_and_outputsplus a hand count inresolve_txid;TxTable::getfrom the spentness probe andput_spendwas never counted, sotx_gets == 0held while a proposal decoded one body per input. OneTxTable::body_decodesRelaxed add on the outs decoder (get_meta_and_outputs, andgetthrough it).get_fullis the confirm write stage's decoder and is not counted. The confirm path reaches the counted decoder only throughStore::get_tx_meta_and_outputs(scripthash collect's cold branch, tip disconnect) andStore::get_tx, the same wrappers the mempool and proposal readers use; on master those wrappers did this one Relaxed add onStore::tx_outs_decodes/tx_gets, so IBD does the word it already did and gains none. That cold decode is already named in theibd: perfinventory assh_collect_cold; no timer is added. The counter lives inTxTablerather than on aStorewrapper because the spentness probe,put_spend, and the spender walks decode insideTxTable, where aStoreword cannot see them.resolve_txidwent throughget_all_by_txid(a packed decode per head candidate) and the three spend paths throughget_by_txid.fks_by_txid(pub(crate)) keeps probe order and thetxid.bodyverify and returns fks only;get_all_by_txidis deleted. Disjoint from f21224f, which already movedget_fk_by_txidto the batch machine.TxOuts (the script and sigop walk from net: pre-0.8 review fixes #967 needs the scripts).createdholds this block'sTxOuts. RAM is O(block outputs + block inputs), dropped at return; the bound is onproposal_connect's rustdoc.TestBlockValidityand this check agree onbad-txns-inputs-missingorspentrather than disagreeing onbad-txns-BIP30. Master's newcheck_block_proposal_rejects_witness_sigops_over_the_limitkeeps its own hub.has_confirmed_strong_spender_atandspenders_rawresolved the create withprobe_body_match_fk(newest row, fence ignored) while the value side is tip-only. With two Class A rows for one txid where the older row is connected and stamped,is_outpoint_spentread the newer row's empty slots and saidfalse. Both readers now useget_fk_by_txid_tip: head probe,txid.bodyverify, one fence read, no decode. No caller needs newest-row semantics: a strong spender always hangs off the connected create, sospenders/spenders_at(Esplora outspend) lose nothing. User-visible fix, so it has its ownFixedfragment.Decode counts (
TxTable::sample_reset_body_decodes)is_outpoint_spent/has_confirmed_strong_spenderput_spend(create resolve)Memo bound
proposal_connectholdscreated: HashMap<OutPoint, TxOut>andconfirmed_parent_outputs: HashMap<OutPoint, (Fk, TxOut)>: this block's outputs and the parent outputs it spends, O(block outputs + block inputs), dropped at return. The returned per-tx prevouts (for the script and sigop walk) are those same spent outputs in block order. Spentness is still probed per input and never served from the memo.Test budget
check_block_proposal_prices_fees_and_rejects_on_one_chainruns ~7.8 s, past the 2 s budget. Regtest coinbase maturity is 100, so a mature spend needs a 101-block chain and no smaller N reaches one; that one boot serves every beat. The tip-child, coinbase-amount, immature-coinbase, and mature-spend beats do not observe each other; they ride the same hub because they only read the 101-block tip the mutating beats (mine the parent at 102, confirm the child at 103, invalidate) then build on, so a separateOnceLockfixture would add store copies and test names without a new observation. The rustdoc says the same.Adversarial review
Findings from the #961 r2/r3 review rounds and their disposition:
has_confirmed_strong_spenderwasfalse, nowtrue. A store unit rather than a journey: the result is pure (which row each reader picks), and reaching two rows for one txid through a session needs a competing stale block, a fixture cost with no extra observation.is_outpoint_spenton memo hit" mutant (Ok(4000)instead of the reject).get_fullcounter on the confirm write stage (IBD): fixed, commit 1;get_fullis no longer counted (rustdoc says so).ibd: perftimer: the branch did the same one Relaxed add on master'sStore::tx_outs_decodes; the word moved structs, the count did not change, andsh_collect_coldalready names the decode. Stated in commit 1's body.body_decodes: removed.check_block_proposalrustdoc claims an SV2 caller: removed; onlygetblocktemplateproposal mode calls it.proposal_connectRAM bound omittedcreated: bound restated as O(block outputs + block inputs).fks_by_txidvisibility:pub(crate).net/proposal-parent-cacheis superseded and left untouched.changelog.d/proposal-parent-once.mdis not edited; this PR addschangelog.d/proposal-parent-followup.md(Changed, commits 1–3) andchangelog.d/proposal-spentness-connected.md(Fixed, commit 5).connected_tx_outputsreturning an unreadTxRecordand the redundanttx_height_get: belongs to the stacked mempool PR, fixed there.Tests
cargo test -p rbitcoin-store --lib: 26 scargo test -p rbitcoin-query --lib: 83 scargo test -p rbitcoin-net --lib: 278 s (known Mac failurepeer::tests::snapshot_omits_peer_after_tcp_finonly)cargo test -p rbitcoin-rpc --lib: 65 s (knownserver::tests::*Mac socket failures only); clippy-D warnings, fmt, ast-grep, deny: green.🤖 Generated with Claude Code