Repository navigation
rpc: report height and chain work for a held block - #1092
Conversation
getblockheader on a held side block left out `height` and reported only that block's own work as `chainwork`. getblock left out `height`, and its chain work was also that one block's when the parent was held or a stored side header. Core reports both for any block it has. A held body has no header row, so both now come from its parent: an active block, a stored header, or another held body. If a held ancestor was evicted from the held-body cache, both stay unknown, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2d7d378 to
569e3f5
Compare
There was a problem hiding this comment.
Verdict
Comment. Four passes (DoS, security, correctness, performance) found no merge blocker. held_height_and_work is the right read-only replacement for held_chainwork_hex: height is parent + steps, chainwork is the ancestor's cumulative work plus each held header above it, once. Active blocks and stored side headers stay on their existing paths. confirmations stays -1, and enrich_block_header_json is still called with height: None, so a held body does not pick up the active chain's chainwork or nextblockhash.
DoS
The walk is a for depth in 1..=OFF_CHAIN_WALK_MAX loop (10_000), not recursion. A real chain stops earlier: HeldBodies::CAP is 320, and held_body returning None aborts the whole result rather than publishing a partial sum. A prev-hash cycle that would burn all 10,000 steps is not something a peer can build, because the block hash commits to prev_blockhash.
The new cost is one held_body clone per held ancestor (see performance). That is bounded by the cache the node already keeps. The active-chain getblock / getblockheader path does not enter the helper. Verbose false and getblock verbosity 0 return before it.
if let Ok(Some(loc)) = off_chain_header(...) treats a store error and "side header walk exceeded" the same as a miss, then tries held_body. A stored header that ensure_header accepted is already within the same 10,000-step cap, so this does not repeat a failed walk in practice. Stopping on Err would match off_chain_header's own contract.
Security
RPC output only. No consensus, store, or validation change. A peer can only affect JSON for a body this node already parked. The response still has "confirmations": -1. getblockcount and getblockchaininfo chainwork stay on the active tip. An equal-work fork reporting the same height and chainwork as the tip is the same shape Core and the existing stored-side-header path already return; the active chain is confirmations > 0 and getchaintips status "active".
bitcoin::Work's + wraps in release and debug_asserts on overflow. This tree already avoids that operator in sum_work / checked_add_work (crates/rbitcoin-net/src/most_work.rs). Overflow is not reachable from public ingress (submitblock rejects high-hash; a target small enough to overflow a 256-bit sum cannot be ground). h.0 + depth is a plain u32 add; off_chain_header uses saturating_add for the same quantity. Both are safe at real heights. Prefer sum_work so a corrupt nBits cannot trip the debug assert.
TCP getblock / getblockheader stay authenticated. --rest (default off) already served these objects with no bearer token; this does not widen that.
Correctness
- Active parent.
height_of_hashis the best-chain index.work_through_heightis the prefix through that height. A side block whose parent is an ancestor of the tip uses that ancestor, not the tip. - Stored side header.
off_chain_headeralready includes that header's own work. The held child addsdepthand the work accumulated above it, so the stored header is not added twice. - Held parent. Each body is added once, then the walk moves to its prev. A miss or eviction drops the partial sum: height is omitted and
chainworkfalls back to this block's own work (same fallback asheld_chainwork_hexon master). The RPC does not error. - Both RPCs call
held_height_and_work_hex. A block that itself has a header row still usesoff_chain_header.
chain_ops_held_height_and_work locks the three parent kinds only for blocks that tie the tip. Regtest work is 2 per block, so a missed add would fail — but copying the tip's height and chainwork into every held block would pass. held_parent is submitted and never read. Its parent is the active block at tip-2, so its own height is tip-1 and its chainwork is not the tip's. Asserting that (and "confirmations": -1) is the check the tip comparison cannot see. Also untested: eviction (height absent, chainwork is this block only) and more than one held ancestor.
The changelog's "stay unknown" matches height. On that path chainwork is still present, as this block's own work.
Performance
Active-chain lookups are unchanged. A held block with an active parent is one height-index lookup, one work_through_height, and one header.work() — same order as the old helper.
A header-less ancestor pays, per step: one height-index lookup, one get_header_by_hash miss (no second ancestry walk), and held_body, which clones the whole Block under the held-body read lock:
pub fn held_body(&self, hash: &BlockHash) -> Option<Block> {
self.held_bodies.read().unwrap().get(hash).cloned()
}
The map stores Arc<Block>, but the clone copies txdata. The lock drops after the copy; peak extra memory is one block. Depth is at most 320, and the work is nBits arithmetic, not proof-of-work. getblock runs the helper once for both fields. A header-only peek would drop the clone. Not required to merge at this cap.
Follow-ups (not blocking)
- Assert
held_parent: heighttip - 1, chainwork equal to the active block attip - 1, andconfirmations == -1. - On the held walk, read the header (or clone the
Arc) instead of the full body. - Add work with
sum_workinstead ofWork's+.
- Held chain work is summed with sum_work instead of Work's `+`, so a corrupt nBits cannot trip the overflow debug assert. A store error from the stored-header lookup ends the walk, instead of counting as a miss. - The journey also checks held_parent: its height and chain work match the active block one below the tip, and every held block reports `confirmations: -1`. Copying the tip's height and work into every held block now fails. - The changelog says what eviction leaves: no height, and chain work is that block's own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@rearden-grok Thanks. I took the small ones in 262d7fe:
I left the header-only peek on the held walk (your item 2) as is, since it is bounded by the 320-body cap. Eviction and longer held chains are still only covered by the harness. Please re-review 262d7fe. |
There was a problem hiding this comment.
Verdict
Approve. Re-reviewed 262d7fe8 against the call sites, the parent walk, and the journey. The earlier blockers I had were test and arithmetic nits, and they are fixed. What is left is cost on an authenticated RPC, which is the right place for this work.
held_height_and_work is only used from held_header_value (getblockheader) and disconnected_block_value (getblock). Active blocks return in getblockheader before that, through height_of_hash. A stored header returns through off_chain_header. Verbose false and getblock verbosity 0 return raw bytes before the walk. This does not run on the peer or validation path.
What the new commit fixes
- Held work is
rbitcoin_net::sum_workof the ancestor base plus each held header above it.bitcoin::Work's+debug-asserts on overflow; this path no longer uses it.height.checked_add(depth)fails closed the same way: no height, andchainworkfalls back to this block. off_chain_header'sErrnow aborts the walk (.ok()?). A miss (Ok(None)) still falls through toheld_body. A store error cannot be reread as a held parent.chain_ops_held_height_and_workreadsheld_parentand requires its height and chainwork to match the active block attip - 1, andconfirmations == -1on both RPCs. Copying the tip into every held block fails that row.on_heldstill locks a two-step walk (active grandparent, one held parent).- The changelog matches the eviction fallback:
heightomitted,chainworkis this block's own work.
Adversarial checks
Accounting. works starts as this block and gains a held parent only in the continue branch. The ancestor that stops the walk is not pushed again. An active parent contributes work_through_height (best-chain prefix through that height). A stored side header contributes off_chain_header's cumulative work, which already includes that header. Height is ancestor + depth, and depth counts from the parent, so a chain active P, held A, held B is height h+2 and work W(P)+A+B.
Wrong chain. height_of_hash is the best-chain index. A side header is not given the active chain's work at the same height. getblock still passes None into enrich_block_header_json, so the new height field does not replace chainwork with the active prefix at that height and does not attach the active nextblockhash. confirmations stays -1.
Failure modes. A missing or evicted ancestor, a sum_work overflow, or a store error returns None. The RPC still succeeds, with no height and only this block's work. It does not publish a partial sum. A prev-hash cycle cannot be built by a peer (the hash commits to prev_blockhash); the loop cap is 10,000 and the held cache is 320.
Scope. Filling that cache is existing p2p behavior. The new work is one RPC that then walks it: per held step, a height-index lookup, a header miss, and held_body's full Block clone under the read lock. The lock drops after the copy. Peak extra memory is one block, depth at most 320. That memcpy is real, and it is an authenticated getblock / getblockheader, not a public ingest path. Leaving the header-only peek out is reasonable at this cap.
Required checks on 262d7fe8 are green (qc, test, windows, macos, coverage).
Residual, not blocking
mediantime for a held body still comes from median_time_from_wire, which only follows stored header rows. A held parent adds no ancestor times. That is unchanged, and confirmations: -1 still marks the block off the active chain. Eviction and a held chain longer than two are not in the journey; the miss path is the held_body()? abort.
Summary
getblockheaderandgetblockon a held side block (a body in RAM with no header row) now reportheightand the cumulativechainwork, as Core does.held_chainwork_hexis replaced by the new helper, so both RPCs use one path.Evidence
The same blocks were sent to bitcoind 31.1 and rbitcoin. Each held block below ties the tip at height 4, where Core's chainwork is
0x0a.getblockheader)0x0a0x020x0a0x0a0x020x0a0x0a0x020x0agetblockmatched thegetblockheaderrow in each case. The one exception isgetblockwith an active parent, where master already had the right chainwork but no height.Test:
chain_ops_held_height_and_work, in the mature-chain RPC journey, submits one held block of each kind. It checksheightandchainworkfrom both RPCs against the tip.Null != 144on the first held block. It also fails with either new branch ofheld_height_and_workdisabled.Merge Danger
Door: two-way
RPC output only. No consensus, store, or schema change.
Blast Radius: RPC JSON
Only held side blocks. Active and archived blocks take the same paths as before.
Known limit: the values still need the block's ancestors. Once a held ancestor is evicted from the 320-body cache,
heightandchainworkstay unknown for the blocks above it, as on master.Found while working on #1089. Core's
submitblockreturnsbad-prevblkfor a block whose parent is invalid, while rbitcoin returnsinconclusive. That is not fixed here: a fix that matches Core needs rbitcoin to stop caching CheckBlock rejects, andduplicate-invalidon resubmit is pinned by the journey.🤖 Generated with Claude Code