From 1cb3fc38d9888dd10bae241e8e3d8fbca8b8f473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 13 May 2026 16:27:15 -0300 Subject: [PATCH 1/3] fix(storage): synthesize empty BlockSignatures for genesis on read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `get_signed_block` short-circuited to `None` whenever the `BlockSignatures` row was missing. That is the by-design state for genesis-style anchor blocks (no proposer signed them, no attestations exist) but it also means BlocksByRoot silently drops the genesis chunk: the leanSpec response is `List[SignedBlock]` and peers (notably the ethereum/hive lean simulator's `blocks_by_root/multiple_known_blocks` test) expect one chunk per requested root. Synthesize an empty `BlockSignatures` instead — zero-filled XMSS proposer signature, empty `AttestationSignatures` list — so fork-choice and BlocksByRoot agree on what the node will serve. --- crates/storage/src/store.rs | 38 +++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 86a258ff..4f242ff7 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -7,16 +7,30 @@ use std::sync::{Arc, LazyLock, Mutex}; /// allowing us to skip storing empty bodies and reconstruct them on read. static EMPTY_BODY_ROOT: LazyLock = LazyLock::new(|| BlockBody::default().hash_tree_root()); +/// Build a placeholder `BlockSignatures` for blocks that were never signed. +/// +/// Genesis-style anchor blocks have no proposer signature and no per-attestation +/// proofs (no attestations exist). `get_signed_block` returns this so peers can +/// still receive the block in BlocksByRoot responses. +fn empty_block_signatures() -> BlockSignatures { + BlockSignatures { + attestation_signatures: AttestationSignatures::default(), + proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]) + .expect("zero-filled signature fits"), + } +} + use crate::api::{StorageBackend, StorageWriteBatch, Table}; use ethlambda_types::{ - attestation::{AttestationData, HashedAttestationData, bits_is_subset}, + attestation::{AttestationData, HashedAttestationData, XmssSignature, bits_is_subset}, block::{ - AggregatedSignatureProof, Block, BlockBody, BlockHeader, BlockSignatures, SignedBlock, + AggregatedSignatureProof, AttestationSignatures, Block, BlockBody, BlockHeader, + BlockSignatures, SignedBlock, }, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, - signature::ValidatorSignature, + signature::{SIGNATURE_SIZE, ValidatorSignature}, state::{ChainConfig, State, anchor_pair_is_consistent}, }; use libssz::{SszDecode, SszEncode}; @@ -990,15 +1004,17 @@ impl Store { /// Get a signed block by combining header, body, and signatures. /// - /// Returns None if any of the components are not found. - /// Note: Genesis block has no entry in BlockSignatures table. + /// Returns None if the header or body (for non-empty bodies) is missing. + /// + /// Signatures are absent for genesis-style anchor blocks (no proposer ever + /// signed them). To keep BlocksByRoot symmetric with the fork-choice view + /// for peers, synthesize empty `BlockSignatures` when the header exists + /// but no signature row was written. pub fn get_signed_block(&self, root: &H256) -> Option { let view = self.backend.begin_read().expect("read view"); let key = root.to_ssz(); let header_bytes = view.get(Table::BlockHeaders, &key).expect("get")?; - let sig_bytes = view.get(Table::BlockSignatures, &key).expect("get")?; - let header = BlockHeader::from_ssz_bytes(&header_bytes).expect("valid header"); // Use empty body if header indicates empty, otherwise fetch from DB @@ -1009,8 +1025,14 @@ impl Store { BlockBody::from_ssz_bytes(&body_bytes).expect("valid body") }; + let signature = match view.get(Table::BlockSignatures, &key).expect("get") { + Some(sig_bytes) => { + BlockSignatures::from_ssz_bytes(&sig_bytes).expect("valid signatures") + } + None => empty_block_signatures(), + }; + let block = Block::from_header_and_body(header, body); - let signature = BlockSignatures::from_ssz_bytes(&sig_bytes).expect("valid signatures"); Some(SignedBlock { message: block, From a3a19000bc88be9938ce511f4615dfbe1e6d3fd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 14 May 2026 18:42:55 -0300 Subject: [PATCH 2/3] fix(types): use SSZ-structurally-valid blank XMSS signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `empty_block_signatures()` and the handful of test/RPC stubs that construct placeholder `SignedBlock`s filled the proposer signature with 2536 raw zero bytes. That worked on the wire (parent containers treat `XmssSignature` as an opaque fixed-size blob per leanSpec `xmss/containers.py::Signature.is_fixed_size`), but the bytes are not a valid SSZ encoding of the inner `Signature` container — the leading offsets are all zero. Any consumer that round-trips the placeholder through the inner-container decoder (e.g. leanSpec API-output validators) would fail. Match ream's `Signature::blank()` (post devnet4 alignment): write the three SSZ offsets at fixed positions so the 2536-byte blob decodes back to `Signature { path = HashTreeOpening { siblings = [0; 32] }, rho = 0, hashes = [0; 46] }`. Centralize the construction in a single `attestation::blank_xmss_signature()` helper and point every existing call site at it; also fix the stale `(3112 bytes)` doc on `XmssSignature` and update the HTTP test that asserted 404-on-genesis to reflect the post-`bbe165c` behavior (the endpoint now serves the genesis block with this placeholder, matching what peers see on BlocksByRoot). --- crates/blockchain/src/store.rs | 11 ++++--- .../common/test-fixtures/src/fork_choice.rs | 6 ++-- crates/common/types/src/attestation.rs | 33 ++++++++++++++++++- crates/net/p2p/src/req_resp/handlers.rs | 5 ++- crates/net/rpc/src/lib.rs | 17 +++++----- crates/storage/src/store.rs | 7 ++-- 6 files changed, 54 insertions(+), 25 deletions(-) diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 35b8dce9..037778f0 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -1356,13 +1356,14 @@ fn reorg_depth(old_head: H256, new_head: H256, store: &Store) -> Option { mod tests { use super::*; use ethlambda_types::{ - attestation::{AggregatedAttestation, AggregationBits, AttestationData, XmssSignature}, + attestation::{ + AggregatedAttestation, AggregationBits, AttestationData, blank_xmss_signature, + }, block::{ AggregatedSignatureProof, AttestationSignatures, BlockBody, BlockSignatures, SignedBlock, }, checkpoint::Checkpoint, - signature::SIGNATURE_SIZE, state::State, }; @@ -1407,7 +1408,7 @@ mod tests { }, signature: BlockSignatures { attestation_signatures, - proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap(), + proposer_signature: blank_xmss_signature(), }, }; @@ -1561,7 +1562,7 @@ mod tests { message: block, signature: BlockSignatures { attestation_signatures: AttestationSignatures::try_from(attestation_sigs).unwrap(), - proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap(), + proposer_signature: blank_xmss_signature(), }, }; @@ -1858,7 +1859,7 @@ mod tests { }, signature: BlockSignatures { attestation_signatures, - proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap(), + proposer_signature: blank_xmss_signature(), }, }; diff --git a/crates/common/test-fixtures/src/fork_choice.rs b/crates/common/test-fixtures/src/fork_choice.rs index 99fedc20..03d5697b 100644 --- a/crates/common/test-fixtures/src/fork_choice.rs +++ b/crates/common/test-fixtures/src/fork_choice.rs @@ -7,12 +7,11 @@ use crate::{ AggregationBits, AttestationData, Block, BlockBody, Checkpoint, TestInfo, TestState, deser_xmss_hex, }; -use ethlambda_types::attestation::XmssSignature; +use ethlambda_types::attestation::{XmssSignature, blank_xmss_signature}; use ethlambda_types::block::{ AggregatedSignatureProof, AttestationSignatures, BlockSignatures, SignedBlock, }; use ethlambda_types::primitives::H256; -use ethlambda_types::signature::SIGNATURE_SIZE; use serde::{Deserialize, Deserializer}; use std::collections::HashMap; use std::path::Path; @@ -171,8 +170,7 @@ impl BlockStepData { SignedBlock { message: block, signature: BlockSignatures { - proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]) - .expect("zero-filled signature has the correct length"), + proposer_signature: blank_xmss_signature(), attestation_signatures: AttestationSignatures::try_from(proofs) .expect("attestation proofs within limit"), }, diff --git a/crates/common/types/src/attestation.rs b/crates/common/types/src/attestation.rs index 7a7ce533..dee0e4c1 100644 --- a/crates/common/types/src/attestation.rs +++ b/crates/common/types/src/attestation.rs @@ -55,9 +55,40 @@ pub struct SignedAttestation { pub signature: XmssSignature, } -/// XMSS signature as a fixed-length byte vector (3112 bytes). +/// XMSS signature as a fixed-length byte vector (`SIGNATURE_SIZE` bytes). pub type XmssSignature = SszVector; +/// SSZ offset (in bytes) of the `path` body inside an XMSS `Signature` container. +/// +/// Layout: 4-byte path offset + 28-byte rho + 4-byte hashes offset = 36. +const SIGNATURE_PATH_OFFSET: u32 = 36; + +/// SSZ offset (in bytes) of the `hashes` body inside an XMSS `Signature`. +/// +/// `path` body is 4-byte siblings offset + LOG_LIFETIME (32) siblings × 32-byte +/// digest = 1028, starting at byte 36, so hashes start at 36 + 1028 = 1064. +const SIGNATURE_HASHES_OFFSET: u32 = 1064; + +/// SSZ offset (in bytes) of the `siblings` list inside the `path` container. +const SIGNATURE_PATH_SIBLINGS_OFFSET: u32 = 4; + +/// Build a placeholder XMSS signature that decodes as a structurally valid +/// leanSpec `Signature` container of all-zero hashes. +/// +/// Used for genesis-style anchor blocks that were never proposed and therefore +/// have no real signature. Parent containers inline this as an opaque +/// `SIGNATURE_SIZE`-byte blob; consumers that decode the inner `Signature` +/// container see `path = HashTreeOpening { siblings = [0; 32] }`, `rho = 0`, +/// `hashes = [0; 46]`. Matches ream's `Signature::blank()` so the wire format +/// is byte-identical across clients. +pub fn blank_xmss_signature() -> XmssSignature { + let mut bytes = vec![0u8; SIGNATURE_SIZE]; + bytes[..4].copy_from_slice(&SIGNATURE_PATH_OFFSET.to_le_bytes()); + bytes[32..36].copy_from_slice(&SIGNATURE_HASHES_OFFSET.to_le_bytes()); + bytes[36..40].copy_from_slice(&SIGNATURE_PATH_SIBLINGS_OFFSET.to_le_bytes()); + XmssSignature::try_from(bytes).expect("size matches SIGNATURE_SIZE") +} + /// Aggregated attestation consisting of participation bits and message. #[derive(Debug, Clone, Serialize, SszEncode, SszDecode, HashTreeRoot)] pub struct AggregatedAttestation { diff --git a/crates/net/p2p/src/req_resp/handlers.rs b/crates/net/p2p/src/req_resp/handlers.rs index b6a025c7..31743316 100644 --- a/crates/net/p2p/src/req_resp/handlers.rs +++ b/crates/net/p2p/src/req_resp/handlers.rs @@ -400,9 +400,8 @@ mod tests { use super::*; use ethlambda_storage::{ForkCheckpoints, backend::InMemoryBackend}; use ethlambda_types::{ - attestation::XmssSignature, + attestation::blank_xmss_signature, block::{Block, BlockBody, BlockSignatures}, - signature::SIGNATURE_SIZE, state::State, }; use libssz_types::SszList; @@ -419,7 +418,7 @@ mod tests { }, signature: BlockSignatures { attestation_signatures: SszList::new(), - proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap(), + proposer_signature: blank_xmss_signature(), }, } } diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index 8a5825b6..cf859ad8 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -494,11 +494,10 @@ mod tests { #[tokio::test] async fn test_get_latest_finalized_block() { use ethlambda_types::{ - attestation::XmssSignature, + attestation::blank_xmss_signature, block::{Block, BlockBody, BlockSignatures, SignedBlock}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, - signature::SIGNATURE_SIZE, }; use libssz::SszEncode; @@ -519,7 +518,7 @@ mod tests { message: block, signature: BlockSignatures { attestation_signatures: Default::default(), - proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap(), + proposer_signature: blank_xmss_signature(), }, }; @@ -559,10 +558,12 @@ mod tests { } #[tokio::test] - async fn test_get_latest_finalized_block_returns_404_when_absent() { - // Genesis-anchored store: init_store writes header + state but no - // BlockSignatures entry, so get_signed_block(genesis_root) returns None - // and the endpoint must report 404 rather than panic. + async fn test_get_latest_finalized_block_serves_genesis_with_placeholder_signature() { + // Genesis-anchored store: `init_store` writes the header + state but no + // `BlockSignatures` row. `get_signed_block` synthesizes an empty + // `BlockSignatures` so peers can still receive the genesis block on + // BlocksByRoot; the HTTP endpoint stays consistent and returns 200 + // rather than 404. let state = create_test_state(); let backend = Arc::new(InMemoryBackend::new()); let store = Store::from_anchor_state(backend, state); @@ -579,6 +580,6 @@ mod tests { .await .unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(response.status(), StatusCode::OK); } } diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 4f242ff7..c2751ee2 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -15,22 +15,21 @@ static EMPTY_BODY_ROOT: LazyLock = LazyLock::new(|| BlockBody::default().h fn empty_block_signatures() -> BlockSignatures { BlockSignatures { attestation_signatures: AttestationSignatures::default(), - proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]) - .expect("zero-filled signature fits"), + proposer_signature: blank_xmss_signature(), } } use crate::api::{StorageBackend, StorageWriteBatch, Table}; use ethlambda_types::{ - attestation::{AttestationData, HashedAttestationData, XmssSignature, bits_is_subset}, + attestation::{AttestationData, HashedAttestationData, bits_is_subset, blank_xmss_signature}, block::{ AggregatedSignatureProof, AttestationSignatures, Block, BlockBody, BlockHeader, BlockSignatures, SignedBlock, }, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, - signature::{SIGNATURE_SIZE, ValidatorSignature}, + signature::ValidatorSignature, state::{ChainConfig, State, anchor_pair_is_consistent}, }; use libssz::{SszDecode, SszEncode}; From c21bc03127759932625e6dd0a310fcf0a1875f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 15 May 2026 11:29:48 -0300 Subject: [PATCH 3/3] fix(storage): scope BlockSignatures synthesis to slot-0 anchor only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewers (Claude, Greptile, Codex) on PR #371 flagged that the new synthesize-on-read fallback was too broad: it covered *any* block whose `BlockSignatures` row was missing, not just the slot-0 anchor. The documented invariant is that non-genesis blocks have entries in all three tables (CLAUDE.md), so a missing signature row for slot > 0 is storage corruption, not a valid placeholder case — but `get_signed_block` was hiding it by fabricating a `SignedBlock` with empty `attestation_signatures`, regardless of what the body actually carried. Guard the synthesis on `header.slot == 0`. For any other slot, fall back to returning `None` (the pre-existing "block not found" semantics). Update the doc on `get_signed_block` to make the scoping explicit. Also add direct coverage for the synthesis path: - `attestation::tests::blank_xmss_signature_has_expected_ssz_offsets` reads the three SSZ offsets back out of `blank_xmss_signature()` and asserts every non-offset byte is zero — catches a one-off in any of the three constants that would silently break inner-`Signature` decoding without changing the outer length. - `storage::store::tests::get_signed_block_synthesizes_blank_signatures_for_genesis_anchor` exercises the synthesis path directly against a `from_anchor_state` store. - `storage::store::tests::get_signed_block_returns_none_for_non_genesis_with_missing_signatures` hand-inserts a slot-1 header without its signature row and confirms the new guard returns `None`. - The RPC test for the genesis-finalized-block endpoint now asserts the response body equals the expected SSZ encoding and the Content-Type header, matching the adjacent `test_get_latest_finalized_block` (per Greptile Issue 2). Cosmetic: move `empty_block_signatures` below the `use` block in `storage/src/store.rs` to follow the file's existing layout. --- crates/common/types/src/attestation.rs | 32 ++++++++ crates/net/rpc/src/lib.rs | 28 +++++++ crates/storage/src/store.rs | 105 +++++++++++++++++++------ 3 files changed, 141 insertions(+), 24 deletions(-) diff --git a/crates/common/types/src/attestation.rs b/crates/common/types/src/attestation.rs index dee0e4c1..d8dd2ada 100644 --- a/crates/common/types/src/attestation.rs +++ b/crates/common/types/src/attestation.rs @@ -306,4 +306,36 @@ mod tests { let b = bits(24, &[0, 1, 16]); assert!(!bits_is_subset(&a, &b)); } + + /// Guard the three SSZ offsets at fixed byte positions so a one-off in any + /// constant doesn't silently produce a blob that still has the right outer + /// length but decodes incorrectly at the inner `Signature` container level. + #[test] + fn blank_xmss_signature_has_expected_ssz_offsets() { + let sig = blank_xmss_signature(); + let bytes: Vec = sig.into_iter().collect(); + + assert_eq!(bytes.len(), SIGNATURE_SIZE); + assert_eq!( + u32::from_le_bytes(bytes[0..4].try_into().unwrap()), + SIGNATURE_PATH_OFFSET, + ); + assert_eq!( + u32::from_le_bytes(bytes[32..36].try_into().unwrap()), + SIGNATURE_HASHES_OFFSET, + ); + assert_eq!( + u32::from_le_bytes(bytes[36..40].try_into().unwrap()), + SIGNATURE_PATH_SIBLINGS_OFFSET, + ); + + // Everything outside the three offset slots must be zero — the + // placeholder is "all-zero hashes" once the offsets locate them. + for (i, b) in bytes.iter().enumerate() { + let in_offset_slot = matches!(i, 0..4 | 32..36 | 36..40); + if !in_offset_slot { + assert_eq!(*b, 0, "non-offset byte at index {i} should be zero"); + } + } + } } diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index cf859ad8..8ce451cf 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -559,6 +559,12 @@ mod tests { #[tokio::test] async fn test_get_latest_finalized_block_serves_genesis_with_placeholder_signature() { + use ethlambda_types::{ + attestation::blank_xmss_signature, + block::{BlockSignatures, SignedBlock}, + }; + use libssz::SszEncode; + // Genesis-anchored store: `init_store` writes the header + state but no // `BlockSignatures` row. `get_signed_block` synthesizes an empty // `BlockSignatures` so peers can still receive the genesis block on @@ -568,6 +574,21 @@ mod tests { let backend = Arc::new(InMemoryBackend::new()); let store = Store::from_anchor_state(backend, state); + // The body the endpoint serves must round-trip to a `SignedBlock` + // matching the genesis header paired with the synthetic blank + // signatures — same shape `get_signed_block` builds in storage. + let genesis_block = store + .get_signed_block(&store.latest_finalized().root) + .expect("genesis served via get_signed_block"); + let expected = SignedBlock { + message: genesis_block.message.clone(), + signature: BlockSignatures { + attestation_signatures: Default::default(), + proposer_signature: blank_xmss_signature(), + }, + }; + let expected_ssz = expected.to_ssz(); + let app = build_api_router(store); let response = app @@ -581,5 +602,12 @@ mod tests { .unwrap(); assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + SSZ_CONTENT_TYPE + ); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(body.as_ref(), expected_ssz.as_slice()); } } diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index c2751ee2..b7e4e6b2 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -1,24 +1,6 @@ use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::sync::{Arc, LazyLock, Mutex}; -/// The tree hash root of an empty block body. -/// -/// Used to detect genesis/anchor blocks that have no attestations, -/// allowing us to skip storing empty bodies and reconstruct them on read. -static EMPTY_BODY_ROOT: LazyLock = LazyLock::new(|| BlockBody::default().hash_tree_root()); - -/// Build a placeholder `BlockSignatures` for blocks that were never signed. -/// -/// Genesis-style anchor blocks have no proposer signature and no per-attestation -/// proofs (no attestations exist). `get_signed_block` returns this so peers can -/// still receive the block in BlocksByRoot responses. -fn empty_block_signatures() -> BlockSignatures { - BlockSignatures { - attestation_signatures: AttestationSignatures::default(), - proposer_signature: blank_xmss_signature(), - } -} - use crate::api::{StorageBackend, StorageWriteBatch, Table}; use ethlambda_types::{ @@ -49,6 +31,24 @@ pub enum GetForkchoiceStoreError { }, } +/// The tree hash root of an empty block body. +/// +/// Used to detect genesis/anchor blocks that have no attestations, +/// allowing us to skip storing empty bodies and reconstruct them on read. +static EMPTY_BODY_ROOT: LazyLock = LazyLock::new(|| BlockBody::default().hash_tree_root()); + +/// Build a placeholder `BlockSignatures` for blocks that were never signed. +/// +/// Genesis-style anchor blocks have no proposer signature and no per-attestation +/// proofs (no attestations exist). `get_signed_block` returns this so peers can +/// still receive the block in BlocksByRoot responses. +fn empty_block_signatures() -> BlockSignatures { + BlockSignatures { + attestation_signatures: AttestationSignatures::default(), + proposer_signature: blank_xmss_signature(), + } +} + /// Checkpoints to update in the forkchoice store. /// /// Used with `Store::update_checkpoints` to update head and optionally @@ -1003,12 +1003,16 @@ impl Store { /// Get a signed block by combining header, body, and signatures. /// - /// Returns None if the header or body (for non-empty bodies) is missing. + /// Returns None if the header or body (for non-empty bodies) is missing, + /// or if the signature row is missing for any block other than the + /// slot-0 anchor. /// - /// Signatures are absent for genesis-style anchor blocks (no proposer ever - /// signed them). To keep BlocksByRoot symmetric with the fork-choice view - /// for peers, synthesize empty `BlockSignatures` when the header exists - /// but no signature row was written. + /// Signatures are absent for genesis-style anchor blocks (no proposer + /// ever signed them). To keep BlocksByRoot symmetric with the + /// fork-choice view for peers, synthesize empty `BlockSignatures` for + /// the slot-0 case only; for any other slot the missing-signature + /// state is treated as storage corruption and surfaces as `None` + /// rather than as a fabricated block. pub fn get_signed_block(&self, root: &H256) -> Option { let view = self.backend.begin_read().expect("read view"); let key = root.to_ssz(); @@ -1028,7 +1032,12 @@ impl Store { Some(sig_bytes) => { BlockSignatures::from_ssz_bytes(&sig_bytes).expect("valid signatures") } - None => empty_block_signatures(), + // Synthesis only covers the genesis-style anchor (slot 0). Any other + // missing-signature case is a storage corruption that should surface + // as `None` rather than fabricating a block whose `attestation_signatures` + // list is empty regardless of what the body actually carries. + None if header.slot == 0 => empty_block_signatures(), + None => return None, }; let block = Block::from_header_and_body(header, body); @@ -2334,4 +2343,52 @@ mod tests { assert_eq!(buf.total_signatures(), 2); // slot 2 (1) + slot 3 (1) assert_eq!(buf.len(), 2); } + + /// `Store::from_anchor_state` writes the header but no `BlockSignatures` + /// row for the slot-0 anchor. `get_signed_block` must synthesize an empty + /// `BlockSignatures` so the genesis block can still be served on + /// BlocksByRoot / `/lean/v0/blocks/finalized`. + #[test] + fn get_signed_block_synthesizes_blank_signatures_for_genesis_anchor() { + let backend: Arc = Arc::new(InMemoryBackend::new()); + let store = Store::from_anchor_state(backend, State::from_genesis(0, vec![])); + + let head_root = store.head(); + let signed = store + .get_signed_block(&head_root) + .expect("genesis block must be retrievable with synthetic signatures"); + + assert_eq!(signed.message.slot, 0); + assert_eq!(signed.signature.proposer_signature, blank_xmss_signature()); + assert_eq!(signed.signature.attestation_signatures.len(), 0); + } + + /// The synthesis branch must be confined to the slot-0 anchor: a + /// non-genesis block whose `BlockSignatures` row is missing is treated + /// as storage corruption and surfaces as `None`, not a fabricated block. + #[test] + fn get_signed_block_returns_none_for_non_genesis_with_missing_signatures() { + let backend: Arc = Arc::new(InMemoryBackend::new()); + + // Hand-insert a slot-1 header (and empty body, via `EMPTY_BODY_ROOT`) + // but skip the `BlockSignatures` row. This mimics the corruption case + // the guard is meant to catch, without going through the normal + // `insert_signed_block` write path which always writes all three rows. + let header = BlockHeader { + slot: 1, + proposer_index: 0, + parent_root: H256::ZERO, + state_root: H256::ZERO, + body_root: *EMPTY_BODY_ROOT, + }; + let root = header.hash_tree_root(); + let mut batch = backend.begin_write().expect("write batch"); + batch + .put_batch(Table::BlockHeaders, vec![(root.to_ssz(), header.to_ssz())]) + .expect("put header"); + batch.commit().expect("commit"); + + let store = Store::from_anchor_state(backend, State::from_genesis(0, vec![])); + assert!(store.get_signed_block(&root).is_none()); + } }