Skip to content

store: count body decodes at the decode site, bound the proposal parent memo, and probe spentness on the connected create - #968

Merged
reardencode merged 5 commits into
reardencode:masterfrom
average-gary:net/proposal-parent-once-followup
Oct 9, 2026
Merged

reardencode merged 5 commits into
reardencode:masterfrom
average-gary:net/proposal-parent-once-followup

Conversation

@average-gary

@average-gary average-gary commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

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_proposal is about to become a hot path. The Stratum V2 job-validation work (sv2-spec discussion #239; rbitcoin branch sv2/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's get_coin runs 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

  1. store: count packed body decodes at the decode site. The two Store counters sat beside utxo_view and only saw Store::get_tx / get_tx_meta_and_outputs plus a hand count in resolve_txid; TxTable::get from the spentness probe and put_spend was never counted, so tx_gets == 0 held while a proposal decoded one body per input. One TxTable::body_decodes Relaxed add on the outs decoder (get_meta_and_outputs, and get through it). get_full is the confirm write stage's decoder and is not counted. The confirm path reaches the counted decoder only through Store::get_tx_meta_and_outputs (scripthash collect's cold branch, tip disconnect) and Store::get_tx, the same wrappers the mempool and proposal readers use; on master those wrappers did this one Relaxed add on Store::tx_outs_decodes / tx_gets, so IBD does the word it already did and gains none. That cold decode is already named in the ibd: perf inventory as sh_collect_cold; no timer is added. The counter lives in TxTable rather than on a Store wrapper because the spentness probe, put_spend, and the spender walks decode inside TxTable, where a Store word cannot see them.
  2. store: resolve txids and spender creates without decoding the body. resolve_txid went through get_all_by_txid (a packed decode per head candidate) and the three spend paths through get_by_txid. fks_by_txid (pub(crate)) keeps probe order and the txid.body verify and returns fks only; get_all_by_txid is deleted. Disjoint from f21224f, which already moved get_fk_by_txid to the batch machine.
  3. net: keep only the outputs a block proposal spends from its parents. A pre-pass groups spent vouts per parent, resolves each parent tip-only, decodes it once, and keeps just the spent vouts as TxOuts (the script and sigop walk from net: pre-0.8 review fixes #967 needs the scripts). created holds this block's TxOuts. RAM is O(block outputs + block inputs), dropped at return; the bound is on proposal_connect's rustdoc.
  4. test: fold the block proposal checks into one chain. Six hubs (12.7 s) became one 101-block journey (7.8 s after the rebase, with the script walk). 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. The second spend is a fresh tx (different payout), so Core's TestBlockValidity and this check agree on bad-txns-inputs-missingorspent rather than disagreeing on bad-txns-BIP30. Master's new check_block_proposal_rejects_witness_sigops_over_the_limit keeps its own hub.
  5. store: probe spentness on the connected create only. has_confirmed_strong_spender_at and spenders_raw resolved the create with probe_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_spent read the newer row's empty slots and said false. Both readers now use get_fk_by_txid_tip: head probe, txid.body verify, one fence read, no decode. No caller needs newest-row semantics: a strong spender always hangs off the connected create, so spenders / spenders_at (Esplora outspend) lose nothing. User-visible fix, so it has its own Fixed fragment.

Decode counts (TxTable::sample_reset_body_decodes)

Path master after #961 this PR
50-child proposal, one fan-out parent 51 (1 outs decode + 50 whole-body decodes from the per-input spentness probe) 1
is_outpoint_spent / has_confirmed_strong_spender 1 per call 0
put_spend (create resolve) 1 0
Proposal spend of a reorged-out row 1 (decoded before the fence miss) 0

Memo bound

proposal_connect holds created: HashMap<OutPoint, TxOut> and confirmed_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_chain runs ~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 separate OnceLock fixture 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:

  • Spentness and value resolve read different rows (risk/medium): fixed, commit 5. Red: store unit test with an older connected + stamped row and a newer unconnected row for one txid; has_confirmed_strong_spender was false, now true. 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.
  • F4 beat reuses a confirmed tx verbatim (BIP30 duplicate): fixed, commit 4; fresh tx spending vout 0. Verified the beat still kills the "skip is_outpoint_spent on memo hit" mutant (Ok(4000) instead of the reject).
  • get_full counter on the confirm write stage (IBD): fixed, commit 1; get_full is no longer counted (rustdoc says so).
  • Counter on the scripthash cold branch needs an ibd: perf timer: the branch did the same one Relaxed add on master's Store::tx_outs_decodes; the word moved structs, the count did not change, and sh_collect_cold already names the decode. Stated in commit 1's body.
  • False cache-line / "last in the table" rustdoc on body_decodes: removed.
  • check_block_proposal rustdoc claims an SV2 caller: removed; only getblocktemplate proposal mode calls it.
  • proposal_connect RAM bound omitted created: bound restated as O(block outputs + block inputs).
  • fks_by_txid visibility: pub(crate).
  • Unsquashed fixups / stale branch: folded; net/proposal-parent-cache is superseded and left untouched.
  • Changelog: the merged changelog.d/proposal-parent-once.md is not edited; this PR adds changelog.d/proposal-parent-followup.md (Changed, commits 1–3) and changelog.d/proposal-spentness-connected.md (Fixed, commit 5).
  • connected_tx_outputs returning an unread TxRecord and the redundant tx_height_get: belongs to the stacked mempool PR, fixed there.

Tests

  • Folded proposal journey: 7.8 s (was 12.7 s across six tests).
  • cargo test -p rbitcoin-store --lib: 26 s
  • cargo test -p rbitcoin-query --lib: 83 s
  • cargo test -p rbitcoin-net --lib: 278 s (known Mac failure peer::tests::snapshot_omits_peer_after_tcp_fin only)
  • cargo test -p rbitcoin-rpc --lib: 65 s (known server::tests::* Mac socket failures only); clippy -D warnings, fmt, ast-grep, deny: green.

🤖 Generated with Claude Code

@rearden-grok rearden-grok Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/rbitcoin-net/src/chain.rs Outdated
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

average-gary and others added 5 commits October 9, 2026 08:58
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>
@average-gary
average-gary force-pushed the net/proposal-parent-once-followup branch from 4cf000c to 8655524 Compare October 9, 2026 13:21

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@reardencode
reardencode merged commit c3f6b29 into reardencode:master Oct 9, 2026
18 checks passed
average-gary added a commit to average-gary/rbitcoin that referenced this pull request Oct 9, 2026
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>
average-gary added a commit to average-gary/rbitcoin that referenced this pull request Oct 9, 2026
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>
average-gary added a commit to average-gary/rbitcoin that referenced this pull request Oct 9, 2026
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>
average-gary added a commit to average-gary/rbitcoin that referenced this pull request Oct 9, 2026
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>
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.

2 participants