Skip to content

net: store a zero-parent header only for genesis - #1094

Merged
reardencode merged 1 commit into
reardencode:masterfrom
smolcars:net/zero-prev-header
Oct 20, 2026
Merged

reardencode merged 1 commit into
reardencode:masterfrom
smolcars:net/zero-prev-header

Conversation

@niteshbalusu11

@niteshbalusu11 niteshbalusu11 commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

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:

unrequested `block` → on_block_unrequested_skip   (skips the unknown-parent ban when prev == 0)
`headers` message   → ensure_headers_batch
submitheader        → process_submitted_header     (prev == 0 counted as known)
                        └ header_sync_prev_fk: prev == 0 → Fk::NULL, no contextual check
 header_sync_prev_fk
   if prev == 0
+    if hash != params.genesis_hash → "header parent unknown"   # same error as any orphan header
     Fk::NULL
 process_submitted_header
-  prev_known = prev == 0 || row(prev) || held(prev)
+  prev_known = (prev == 0 && hash == genesis) || row(prev) || held(prev)

The unrequested-block path already bans when ensure_header fails, so a zero-parent block now gets Core's missing-parent penalty. A zero-parent headers message 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 calls accept_received_block directly, so it never reaches this header write.

Evidence

Each node got three unrequested block messages with prev = 0 (one with valid regtest PoW, two with nBits = 0x1d00ffff and no PoW), then submitheader with prev = 0:

Core 31.1 master this PR
peer after the blocks disconnected kept disconnected
headers stored (getblockheader) none all 3, including the two with no PoW none
submitheader -25 Must submit previous header (00…00) first null (stored) -25 Must submit previous header

Tests:

  • chain::tests::ensure_headers_batch_chain_and_missing_parent:

    • A zero-parent header fails ensure_header and ensure_headers_batch with header parent unknown.
    • process_submitted_header gives Must submit previous header.
    • No row is written, and genesis still writes.
  • 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 on Ok(()). The journey fails with an unrequested block with an all-zero parent must disconnect.
    After: both pass. cargo test -p rbitcoin-net --lib is 923 passed, including chain_edges_pin_work_headers_and_reopen, which still accepts submitheader(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_height already 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

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>

@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. 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.

@reardencode
reardencode merged commit 81f1ad7 into reardencode:master Oct 20, 2026
18 checks passed
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