From b585c2a22737eb5de1308ccdd043a680df630061 Mon Sep 17 00:00:00 2001 From: Nitesh Balusu Date: Mon, 19 Oct 2026 19:45:40 -0400 Subject: [PATCH] net: store a zero-parent header only for genesis 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 --- changelog.d/zero-prev-header.md | 8 ++++++ crates/rbitcoin-net/src/chain.rs | 27 ++++++++++++++++++- .../src/peer_header_dos_journey.rs | 19 +++++++++++++ docs/external_findings/030-header-accept.md | 7 +++++ 4 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 changelog.d/zero-prev-header.md diff --git a/changelog.d/zero-prev-header.md b/changelog.d/zero-prev-header.md new file mode 100644 index 0000000000..c594bb79d3 --- /dev/null +++ b/changelog.d/zero-prev-header.md @@ -0,0 +1,8 @@ +Fixed + +- **A header with an all-zero parent is stored only for genesis.** Any + other such header was written to the header store with no proof-of-work + or context check, from an unrequested block, a `headers` message, or + `submitheader`. New rows are now refused, as in Core. An unrequested + `block` with such a header gets the usual misbehavior penalty, and + `submitheader` returns `Must submit previous header`. diff --git a/crates/rbitcoin-net/src/chain.rs b/crates/rbitcoin-net/src/chain.rs index b960f8a644..a93f0e5d81 100644 --- a/crates/rbitcoin-net/src/chain.rs +++ b/crates/rbitcoin-net/src/chain.rs @@ -1409,7 +1409,8 @@ impl ChainHub { let prev = header.prev_blockhash; let prev_bytes = prev.to_byte_array(); // Same for the parent, plus a body parked with no header row yet. - let prev_known = prev_bytes == [0u8; 32] + // An all-zero parent is known only to genesis. + let prev_known = (prev_bytes == [0u8; 32] && hash == self.params.genesis_hash) || self .query .get_header_by_hash(&prev_bytes) @@ -1516,6 +1517,13 @@ impl ChainHub { ) -> Result { let prev_bytes = header.prev_blockhash.to_byte_array(); let prev_fk = if prev_bytes == [0u8; 32] { + // Only genesis has no parent. Any other all-zero parent is + // missing, as in Core, and gets no row. + if header.block_hash() != self.params.genesis_hash { + return Err(NetError::Consensus( + "header parent unknown — ensure parent before child".into(), + )); + } Fk::NULL } else if let Some(n) = in_batch.get(&prev_bytes) { n.fk @@ -6064,6 +6072,23 @@ mod tests { .to_string() .contains("header parent unknown")); assert_eq!(hub.query.store().header_count(), after_ok); + // Only genesis has an all-zero parent. Any other zero-parent header + // is an orphan, as in Core, and writes no row. + let mut zero = b4.header; + zero.prev_blockhash = BlockHash::from_byte_array([0u8; 32]); + rbitcoin_consensus::grind_regtest_pow(&mut zero); + let err = hub.ensure_header(&zero).unwrap_err(); + assert!(err.to_string().contains("header parent unknown"), "{err}"); + let err = hub.ensure_headers_batch(&[zero, b4.header]).unwrap_err(); + assert!(err.to_string().contains("header parent unknown"), "{err}"); + assert_eq!( + hub.process_submitted_header(&zero).unwrap_err(), + "Must submit previous header" + ); + assert_eq!(hub.query.store().header_count(), after_ok); + assert!(!hub.knows_header(&zero.block_hash())); + hub.ensure_header(&rbitcoin_consensus::genesis_block(&hub.params).header) + .expect("genesis still writes its zero-parent row"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/rbitcoin-net/src/peer_header_dos_journey.rs b/crates/rbitcoin-net/src/peer_header_dos_journey.rs index 32f1964544..a95b63e282 100644 --- a/crates/rbitcoin-net/src/peer_header_dos_journey.rs +++ b/crates/rbitcoin-net/src/peer_header_dos_journey.rs @@ -727,6 +727,25 @@ async fn header_reject_punishes_except_time(hub: &crate::chain::ChainHub) { "an unrequested header with bad pow must disconnect" ); + // Core: a missing parent is `prev-blk-not-found` and Misbehaving. An + // all-zero parent is missing too unless the block is genesis. + let mut zero_prev = rbitcoin_consensus::mine_regtest_paying( + gen, + tip_time.saturating_add(2), + next_h, + op_true(), + vec![], + ); + zero_prev.header.prev_blockhash = zero_stop(); + rbitcoin_consensus::grind_regtest_pow(&mut zero_prev.header); + let mut follow = PeerFollowState::new(); + push(hub, &out_tx, &mut follow, None, NetworkMessage::Block(zero_prev.clone())).await; + assert!( + follow.ban_score >= BAN_SCORE_THRESHOLD, + "an unrequested block with an all-zero parent must disconnect" + ); + assert!(!hub.knows_header(&zero_prev.block_hash())); + let now = u32::try_from( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/docs/external_findings/030-header-accept.md b/docs/external_findings/030-header-accept.md index 2e84790342..3a6ccc45f7 100644 --- a/docs/external_findings/030-header-accept.md +++ b/docs/external_findings/030-header-accept.md @@ -9,6 +9,13 @@ accepted. A non-genesis block with an all-zero previous hash is not held and does not disconnect the tip. Header work is summed with a checked add. A zero `nBits` target is not passed to `Header::work`. +Header intake also refuses a new zero-parent non-genesis row, from a +single header, a `headers` batch, or `submitheader`. An unrequested block +with such a header gets the usual misbehavior penalty. Rows written by +older versions stay in the store. + **Regression:** `rbitcoin-net` `chain::tests::hostile_peer_session`, +`chain::tests::ensure_headers_batch_chain_and_missing_parent`, +`peer::tests::peer_header_dos_and_self_announce`, `most_work::tests::sum_work_and_work_better`