Repository navigation
net: store a zero-parent header only for genesis - #1094
Conversation
The header write path read an all-zero previous hash as genesis, so any such header got a row with no proof-of-work or context check. An unrequested block, a headers message, or submitheader could write one, and the sender was not penalised. Core treats the parent as missing. Refuse the zero parent unless the header is this network's genesis. The unrequested-block path already bans when that write fails, and submitheader now answers Must submit previous header. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d380496 to
b585c2a
Compare
There was a problem hiding this comment.
Approve. A non-genesis header with prev = 0 can no longer take the Fk::NULL write, which is the hole: that branch skipped proof-of-work and context checks and left a header row.
What holds
header_sync_prev_fk is the only prev-fk decision for ensure_header, ensure_headers_batch, and process_submitted_header. The new check runs before the prev == 0 skip of header_sync_contextual, and it compares header.block_hash() to params.genesis_hash, so the header bytes have to be this network's genesis. submitheader rejects the same case one step earlier (prev_known), and ensure_header is still the backstop if that check were ever true for another reason.
A failed batch returns before query.ensure_headers, so [zero, …] writes nothing. IBD ensure_accepted_prefix only notes a path for a prefix ensure_headers_batch accepted, so a leading null-prev header does not enter hash_height or the ordered window.
Unrequested block still falls through on_block_unrequested_skip (null prev skips the early unknown-parent ban) into ensure_header, and any non-time error calls misbehaving. One call adds the full ban threshold, same as the other header rejects in this crate. Requested blocks hit the same ensure_header error after the skip returns.
Connect does not reopen the write. accept_incoming / accept_branch_fork_height still refuse a null prev once a tip exists. Height 0 goes through check_header, which requires genesis_hash. class_a_header_and_txids and block_to_apply still map a null prev to Fk::NULL, and commit_class_a_* is only used from tests; the confirm stamp checks the genesis hash at height 0 before that assignment matters.
note_header_tip only records a tip when the parent is connected or already a header tip. A null hash is neither, so a rejected (or legacy) null-prev header does not move best_header_height.
Residuals, not blocking
header_announcement_connects still treats a null previous hash as connecting (is_genesis_hash is == [0; 32], not the network genesis). The row is still refused inside persist_pending_header_path. On a tall tip, one valid-PoW null-prev header sums as a height-1 chain and announced_tip_is_hopeless disconnects the peer. Invalid PoW does not sum, so that headers message is pending-map only (capped) and is not banned. The PR text says a headers message is refused with no penalty. The valid-PoW case disconnects via the existing stale-fork rule. It does not store a row.
A row already stored by an older build skips header_sync_prev_fk on the duplicate short-circuit in ensure_headers_batch. A later header in that batch can attach and be context-checked against the poisoned parent, so the table can still grow off that root. Those children do not become header tips, and they still cannot connect as a null-prev branch. That matches the stated blast radius. Quarantine of old null-prev non-genesis rows is a separate change if a poisoned datadir matters.
Tests and CI
ensure_headers_batch_chain_and_missing_parent covers ensure_header, the batch, and submitheader, and checks the row count. The journey covers the unrequested-block ban and knows_header == false for a valid-PoW body. A no-PoW null prev takes the same error branch (the reject is before the context check). qc, test, windows, macos, CodeQL, and coverage are green on b585c2a.
No schema change and no consensus-rule change. Genesis still takes Fk::NULL.
Summary
A header whose previous-block hash is all zeros was treated as "genesis" on the header write path. So any such header got a row in the header store with no proof-of-work or contextual check, and the sender was never penalised. The three ways in:
The unrequested-block path already bans when
ensure_headerfails, so a zero-parent block now gets Core's missing-parent penalty. A zero-parentheadersmessage alone is refused without a penalty, as Core handles a headers message that does not connect. Finding 030's doc now lists this case and the two new regression tests. Finding 030 already stops such a block from being held or touching the tip. Its regression test callsaccept_received_blockdirectly, so it never reaches this header write.Evidence
Each node got three unrequested
blockmessages withprev = 0(one with valid regtest PoW, two withnBits = 0x1d00ffffand no PoW), thensubmitheaderwithprev = 0:getblockheader)submitheader-25 Must submit previous header (00…00) firstnull(stored)-25 Must submit previous headerTests:
chain::tests::ensure_headers_batch_chain_and_missing_parent:ensure_headerandensure_headers_batchwithheader parent unknown.process_submitted_headergivesMust submit previous header.peer_header_dos_and_self_announce(header_reject_punishes_except_time): an unrequested block with a zero parent and valid PoW reaches the ban threshold, and its header is unknown.Before:
ensure_header(&zero).unwrap_err()panics onOk(()). The journey fails withan unrequested block with an all-zero parent must disconnect.After: both pass.
cargo test -p rbitcoin-net --libis 923 passed, includingchain_edges_pin_work_headers_and_reopen, which still acceptssubmitheader(genesis)on a bare hub.Local: fmt, clippy
-D warnings, ast-grep,cargo test --workspace --exclude rbitcoin-bench,ci-os-smoke.sh.Merge Danger
Door: two-way
No schema change, no consensus rule. A zero-parent header can never connect (
accept_branch_fork_heightalready refuses it), so no valid chain is affected.Blast Radius: header intake
Unrequested zero-parent blocks now get Core-style peer misbehavior handling. Rows already written by older versions stay in the store; this PR only stops new ones.
🤖 Generated with Claude Code