Skip to content

net: pre-0.8 review fixes - #967

Merged
reardencode merged 9 commits into
masterfrom
fix/pre-08-review
Oct 9, 2026
Merged

reardencode merged 9 commits into
masterfrom
fix/pre-08-review

Conversation

@rearden-grok

@rearden-grok rearden-grok Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • A held side branch is header-checked before any tip disconnect, so claimed work without a valid proof of work cannot rewind a synced tip.
  • invalidateblock uses the same equal-work tie as an ordinary reorg: more work, then preciousblock, then the earlier held tip.
  • A block proposal whose fee sum overflows, or whose scripts fail, is rejected. Each parent is still decoded once (net: decode each parent once per block proposal check #961).
  • Pure replace-by-fee-rate compares the full product in u128.
  • A package is trimmed once, after every member is in.
  • getblockfilter refuses an unsealed best-chain gap. A sealed parent still allows that one block to be built.
  • Rebased onto master after net: decode each parent once per block proposal check #961 merged.

Test plan

  • reorg_same_height_then_multi_block_branch (fake-work side branch, precious invalidate)
  • Proposal money, proposal scripts, pure RBFR width, package trim once, getblockfilter unsealed gap
  • Required CI green

reardencode and others added 7 commits October 8, 2026 16:59
A later header that claims work without a valid proof of work must not rewind a synced tip. A heavier valid held branch can still become the tip.

Co-authored-by: Cursor <cursoragent@cursor.com>
More total work still wins. Equal work then prefers the precious held branch over the earlier one.

Co-authored-by: Cursor <cursoragent@cursor.com>
Checked addition replaces saturating and wrapping sums, so an output, input, or fee total past u64 is a money-range reject instead of a successful proposal.

Co-authored-by: Cursor <cursoragent@cursor.com>
Proposal mode runs the block script flags on prevouts the fee walk already resolved, after the coinbase amount check.

Co-authored-by: Cursor <cursoragent@cursor.com>
A saturating u64 multiply treats two overflowing sides as equal and can accept a replacement below 1.25x.

Co-authored-by: Cursor <cursoragent@cursor.com>
A parent under the fee floor alone was evicted between package commits, so a package that fits answered mempool full.

Co-authored-by: Cursor <cursoragent@cursor.com>
Rebuilding every unsealed ancestor to answer one hash walks the chain. A sealed parent still allows that one block to be built.

Co-authored-by: Cursor <cursoragent@cursor.com>
@rearden-grok
rearden-grok Bot force-pushed the fix/pre-08-review branch from 94a3e93 to a252f30 Compare October 9, 2026 00:00
The trim now runs after every member is in. A member that does not survive it had already replaced its conflicts, and those conflicts have to come back.

Co-authored-by: Cursor <cursoragent@cursor.com>
@reardencode
reardencode marked this pull request as ready for review October 9, 2026 01:18

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Review of #967

The precious tie helper, the u128 RBFR compare, the checked proposal walk, and the deferred package trim all read correctly. The new journeys pin the bugs they fix. Two things should change before 0.8.0: one in the header check and one in getblockfilter.

Should fix

  1. A store error in the new header check marks a held block permanently invalid. validate_header reads the store. header_reject turns ConsensusError::Store into NetError::Store, and accept_branch_check_headers then wraps every error as ConnectFailed { hash, .. }. In remember_failed_accept, any error that carries a block hash goes to note_invalid_block. So one transient fault (SQ backpressure, a Stale read, a cancel) blacklists a valid side block until restart or reconsiderblock. Wrap only NetError::Consensus and return other errors unchanged. (inline)

  2. getblockfilter now refuses a stale branch more than one block deep, even when the index is fully caught up. A stale block's parent is not on the best chain, so parent_basic_filter_header rejects it. Core's rpc_getblockfilter.py calls getblockfilter on every getchaintips entry, including a multi-block stale fork. The core-functional job was skipped on this PR, so CI did not see this. The plan only asked to refuse an unsealed best-chain gap. Suggested fix: walk a stale branch back to its fork point, and refuse only when that fork point's filter header is not sealed (that is the bounded walk). The stale-block assertion that was deleted from getblockfilter_rebuilds_unsealed_and_chains_from_a_sealed_parent should come back in that form.

    • These still describe the old behavior: changelog.d/core-functional-closer.md (the same release says the opposite of the new fragment), docs/rpc.md lines 59 and 161, docs/operator/operations.md line 94, and docs/wallets.md line 148.
    • Error text: Core says Filter not found. Block filters are still in the process of being indexed., and /rest/blockfilter/ already uses that string. Index is not caught up is new text that neither Core nor this repo used before.

Worth doing

  1. getblockfilter does all the expensive work before it refuses. For an unsealed best-chain height, basic_filter_for_hash rebuilds the block and reads every spent prevout. Only then does it learn the parent is unsealed. Each refused call still costs a full block decode plus prevout reads. Check the parent header first. (inline)

  2. A proposal still skips the P2SH and witness sigop cost. validate_block_structure counts only legacy sigops. With prevouts now in hand, tx_sigop_cost(tx, prev_spks, bip16, witness) can enforce the 80 000 limit in the same loop (bad-blk-sigops), so a proposal over the limit stops reporting valid. (inline)

  3. The 1p1c rollback after trim has no test. The accept_package branch is covered: the failed_replacement_keeps_* package cases failed without it. Nothing reaches the new admit_1p1c branch where the trim drops the parent or child and rollback_package_accepted(&[parent_res, r]) runs. The repo rule is a failing test before any production change. (inline)

Notes, no change needed

  • The trim is still not atomic with the package. The write lock is released between member commits, so a concurrent single admit (defer_trim: false) can trim between them. The window is much smaller than before, but it is not closed.
  • The proposal money strings fire on u64 overflow, not on MAX_MONEY as Core's MoneyRange does, and Core's accumulated-fee reason is bad-txns-accumulated-fee-outofrange. Neither case is reachable with real coins, so this only matters for string parity.
  • try_apply_after_invalidate and best_held_branch now share held_branch_beats, and the precious-missing case (seq = u64::MAX) is handled by checking the precious bit first. Looks right.

validate_header_on_parent(&self.params, Height(height), &b.header, mtp, expected)
.map_err(|e| header_reject(&b.header, &e))
};
if let Err(e) = checked {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

header_reject can return NetError::Store (validate_header reads header_at_height and runs the median-time-past walk against the store). Wrapping it as ConnectFailed { hash, .. } makes remember_failed_accept call note_invalid_block(hash), which permanently invalidates a valid block on a transient IO fault. Suggest:

if let Err(e) = checked {
    return Err(match e {
        NetError::Consensus(msg) => NetError::ConnectFailed {
            hash: b.block_hash().to_byte_array(),
            msg,
        },
        other => other,
    });
}

.ok_or(StoreError::Corrupt(
"invariant: blockfilter parent body missing",
))
Err(StoreError::Rejected("Index is not caught up"))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This also refuses a stale block whose parent is stale. A two-block stale fork can never be served, even with the index caught up, which breaks Core rpc_getblockfilter.py (it queries every chaintip). Suggest walking back to the fork point for blocks that are not on the best chain, and refusing only when that fork point's header is not sealed. Core's text for this case is Filter not found. Block filters are still in the process of being indexed. (/rest/blockfilter/ already uses it).

/// its Class A body only when the previous filter header is already
/// sealed (the zero header, for genesis). An unsealed best-chain gap is
/// refused instead of rebuilt back to genesis.
pub fn basic_filter_for_hash(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

In this function, the unsealed best-chain arm (reconstruct_block_at_height, then spent_scripts reading every prevout in basic_filter_from_wire_block) runs before parent_basic_filter_header refuses. Look up the parent's sealed header first, so a refused call does not pay for a whole-block rebuild.

&block.block_hash().to_byte_array(),
mtp,
);
for (tx, ins) in block.txdata.iter().skip(1).zip(prevouts) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

With ins in hand, this loop can also add tx_sigop_cost(tx, &prev_spks, bip16, witness) and reject past 80 000 with bad-blk-sigops. Structure only counts legacy sigops, so a proposal over the limit through P2SH or witness sigops still reports valid.

Ok(r) => {
self.trim_over_budget();
if !self.try_contains(&parent_res.txid) || !self.try_contains(&r.txid) {
self.rollback_package_accepted(&[parent_res, r]);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No test reaches this branch, where the deferred trim drops the parent or child of a 1p1c and the conflicts it replaced are restored. The accept_package twin is pinned by the failed_replacement_keeps_* package cases. A 1p1c case in one of those journeys, through the orphan path, would cover this one.

A store fault during the held-branch header check no longer marks the block invalid. getblockfilter walks a stale branch only back to a sealed fork point and uses Core's not-indexed error. A proposal counts P2SH and witness sigops, and a 1p1c trim that drops the package restores the conflict it replaced.

Co-authored-by: Cursor <cursoragent@cursor.com>
@rearden-grok

rearden-grok Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in c12b688.

  • A store fault from the held-branch header check stays NetError::Store, so it is not cached as an invalid block. A consensus failure still names the block.
  • getblockfilter walks a stale branch only back to its best-chain fork point, and only when that fork point's filter header is sealed. The error text matches Core and /rest/blockfilter/: Filter not found. Block filters are still in the process of being indexed. The parent header is resolved before the body rebuild. Docs and the unreleased changelog fragments match.
  • A proposal counts P2SH and witness sigops toward the 80_000 limit (bad-blk-sigops) before it runs scripts.
  • failed_1p1c_trim_restores_the_conflict covers the orphan 1p1c path where the deferred trim drops the package and the replaced conflict comes back.

Left as noted: the package trim is still not atomic with a concurrent single admit, and the proposal money strings stay on u64 overflow rather than Core's MoneyRange text.

@reardencode
reardencode merged commit 8ea2f8d into master Oct 9, 2026
23 of 24 checks passed
@rearden-grok
rearden-grok Bot deleted the fix/pre-08-review branch October 9, 2026 15:35
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
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.

1 participant