Skip to content

Commit a854b5e

Browse files
fix: serve genesis block on BlocksByRoot with structurally-valid blank XMSS signature (lambdaclass#371)
## Summary Two related fixes that let peers (and our HTTP API) receive the genesis block in response to BlocksByRoot / \`/lean/v0/blocks/finalized\`. ### 1. \`fix(storage): synthesize empty BlockSignatures for genesis on read\` \`store.get_signed_block(root)\` previously short-circuited to \`None\` when the \`BlockSignatures\` row was missing. That row is intentionally absent for genesis-style anchor blocks (no proposer ever signed them, no attestations exist) — but it also meant we silently dropped the genesis chunk from BlocksByRoot responses. The lean spec response container 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\` when the header exists but no signature row was written, so the fork-choice view and BlocksByRoot agree on what the node will serve. Same shape ream pre-materializes at write time (\`bin/ream/src/main.rs:301-306\`); we synthesize on read because our split storage layout (\`BlockHeaders\` / \`BlockBodies\` / \`BlockSignatures\`) would otherwise pin a 2536-byte zero blob per anchor. ### 2. \`fix(types): use SSZ-structurally-valid blank XMSS signature\` The placeholder \`proposer_signature\` in (1) — and a handful of test / RPC stubs scattered around the workspace — was built as 2536 raw zero bytes. That works on the wire because parent containers treat \`XmssSignature\` as an opaque fixed-size blob (leanSpec \`xmss/containers.py::Signature.is_fixed_size\` returns \`True\`), but the bytes are not a valid SSZ encoding of the inner \`Signature\` container: all offsets are zero. Any consumer that round-trips the placeholder through the inner-container decoder (e.g. leanSpec API-output validators) would reject it. Match ream's [\`Signature::blank()\`](https://github.com/ReamLabs/ream/blob/master/crates/crypto/post_quantum/src/leansig/signature.rs#L36-L48) (post devnet4 alignment): write the three SSZ offsets at fixed positions (36, 1064, 4) so the same 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 (storage helper, blockchain/p2p/rpc test stubs, fork-choice spec-test fixture builder). Also: - fix the stale \`(3112 bytes)\` doc on \`XmssSignature\` (we're 2536 — DIM=46); - update the HTTP test that asserted 404-on-genesis (\`get_latest_finalized_block\`) to assert 200 instead, so it stays consistent with the BlocksByRoot path. ## Why bundled (2) depends on (1) — the \`empty_block_signatures()\` helper it touches is introduced by (1). Keeping them in one PR avoids a half-step where genesis is served on the wire with bytes that no one can decode back to a \`Signature\` container. ## Test plan - [x] \`make fmt\` - [x] \`make lint\` (\`-D warnings\`) - [x] \`cargo test --workspace\` — all suites green locally - [x] hive lean \`reqresp\` suite — 22/22 pass against an image built from this branch (verified previously when only (1) was in place; (2) is a wire-format refinement of the same placeholder) ## Cross-client context ream pre-materializes the same placeholder at write time when constructing the anchor SignedBlock (\`bin/ream/src/main.rs:283-310\` calls \`Signature::blank()\`). Lighthouse does the equivalent for the beacon chain — see [\`beacon_node/beacon_chain/src/builder.rs::genesis_block\`](https://github.com/sigp/lighthouse/blob/stable/beacon_node/beacon_chain/src/builder.rs) which wraps the genesis block in \`SignedBeaconBlock::from_block(genesis_block, Signature::empty())\` with the inline comment *"Empty signature, which should NEVER be read. This isn't to-spec, but makes the genesis block consistent with every other block."* The Ethereum consensus-specs are silent on what shape this placeholder takes (\`specs/phase0/p2p-interface.md\` \`BeaconBlocksByRoot\` allows peers to silently skip out-of-range roots), so this is by-convention across clients rather than a spec requirement. ## Risk - Wire format unchanged for already-signed blocks; only the placeholder bytes change. - Genesis is now served via BlocksByRoot and \`/lean/v0/blocks/finalized\` instead of being silently dropped / 404'd. Any consumer that *required* the 404 to detect "node hasn't progressed past genesis" needs to switch to checking \`latest_finalized.slot == 0\`. I'm not aware of one in this codebase. --------- Co-authored-by: Pablo Deymonnaz <pdeymon@fi.uba.ar>
1 parent 48fd756 commit a854b5e

6 files changed

Lines changed: 202 additions & 34 deletions

File tree

crates/blockchain/src/store.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,13 +1356,14 @@ fn reorg_depth(old_head: H256, new_head: H256, store: &Store) -> Option<u64> {
13561356
mod tests {
13571357
use super::*;
13581358
use ethlambda_types::{
1359-
attestation::{AggregatedAttestation, AggregationBits, AttestationData, XmssSignature},
1359+
attestation::{
1360+
AggregatedAttestation, AggregationBits, AttestationData, blank_xmss_signature,
1361+
},
13601362
block::{
13611363
AggregatedSignatureProof, AttestationSignatures, BlockBody, BlockSignatures,
13621364
SignedBlock,
13631365
},
13641366
checkpoint::Checkpoint,
1365-
signature::SIGNATURE_SIZE,
13661367
state::State,
13671368
};
13681369

@@ -1407,7 +1408,7 @@ mod tests {
14071408
},
14081409
signature: BlockSignatures {
14091410
attestation_signatures,
1410-
proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap(),
1411+
proposer_signature: blank_xmss_signature(),
14111412
},
14121413
};
14131414

@@ -1561,7 +1562,7 @@ mod tests {
15611562
message: block,
15621563
signature: BlockSignatures {
15631564
attestation_signatures: AttestationSignatures::try_from(attestation_sigs).unwrap(),
1564-
proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap(),
1565+
proposer_signature: blank_xmss_signature(),
15651566
},
15661567
};
15671568

@@ -1858,7 +1859,7 @@ mod tests {
18581859
},
18591860
signature: BlockSignatures {
18601861
attestation_signatures,
1861-
proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap(),
1862+
proposer_signature: blank_xmss_signature(),
18621863
},
18631864
};
18641865

crates/common/test-fixtures/src/fork_choice.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@ use crate::{
77
AggregationBits, AttestationData, Block, BlockBody, Checkpoint, TestInfo, TestState,
88
deser_xmss_hex,
99
};
10-
use ethlambda_types::attestation::XmssSignature;
10+
use ethlambda_types::attestation::{XmssSignature, blank_xmss_signature};
1111
use ethlambda_types::block::{
1212
AggregatedSignatureProof, AttestationSignatures, BlockSignatures, SignedBlock,
1313
};
1414
use ethlambda_types::primitives::H256;
15-
use ethlambda_types::signature::SIGNATURE_SIZE;
1615
use serde::{Deserialize, Deserializer};
1716
use std::collections::HashMap;
1817
use std::path::Path;
@@ -171,8 +170,7 @@ impl BlockStepData {
171170
SignedBlock {
172171
message: block,
173172
signature: BlockSignatures {
174-
proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE])
175-
.expect("zero-filled signature has the correct length"),
173+
proposer_signature: blank_xmss_signature(),
176174
attestation_signatures: AttestationSignatures::try_from(proofs)
177175
.expect("attestation proofs within limit"),
178176
},

crates/common/types/src/attestation.rs

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,40 @@ pub struct SignedAttestation {
5555
pub signature: XmssSignature,
5656
}
5757

58-
/// XMSS signature as a fixed-length byte vector (3112 bytes).
58+
/// XMSS signature as a fixed-length byte vector (`SIGNATURE_SIZE` bytes).
5959
pub type XmssSignature = SszVector<u8, SIGNATURE_SIZE>;
6060

61+
/// SSZ offset (in bytes) of the `path` body inside an XMSS `Signature` container.
62+
///
63+
/// Layout: 4-byte path offset + 28-byte rho + 4-byte hashes offset = 36.
64+
const SIGNATURE_PATH_OFFSET: u32 = 36;
65+
66+
/// SSZ offset (in bytes) of the `hashes` body inside an XMSS `Signature`.
67+
///
68+
/// `path` body is 4-byte siblings offset + LOG_LIFETIME (32) siblings × 32-byte
69+
/// digest = 1028, starting at byte 36, so hashes start at 36 + 1028 = 1064.
70+
const SIGNATURE_HASHES_OFFSET: u32 = 1064;
71+
72+
/// SSZ offset (in bytes) of the `siblings` list inside the `path` container.
73+
const SIGNATURE_PATH_SIBLINGS_OFFSET: u32 = 4;
74+
75+
/// Build a placeholder XMSS signature that decodes as a structurally valid
76+
/// leanSpec `Signature` container of all-zero hashes.
77+
///
78+
/// Used for genesis-style anchor blocks that were never proposed and therefore
79+
/// have no real signature. Parent containers inline this as an opaque
80+
/// `SIGNATURE_SIZE`-byte blob; consumers that decode the inner `Signature`
81+
/// container see `path = HashTreeOpening { siblings = [0; 32] }`, `rho = 0`,
82+
/// `hashes = [0; 46]`. Matches ream's `Signature::blank()` so the wire format
83+
/// is byte-identical across clients.
84+
pub fn blank_xmss_signature() -> XmssSignature {
85+
let mut bytes = vec![0u8; SIGNATURE_SIZE];
86+
bytes[..4].copy_from_slice(&SIGNATURE_PATH_OFFSET.to_le_bytes());
87+
bytes[32..36].copy_from_slice(&SIGNATURE_HASHES_OFFSET.to_le_bytes());
88+
bytes[36..40].copy_from_slice(&SIGNATURE_PATH_SIBLINGS_OFFSET.to_le_bytes());
89+
XmssSignature::try_from(bytes).expect("size matches SIGNATURE_SIZE")
90+
}
91+
6192
/// Aggregated attestation consisting of participation bits and message.
6293
#[derive(Debug, Clone, Serialize, SszEncode, SszDecode, HashTreeRoot)]
6394
pub struct AggregatedAttestation {
@@ -275,4 +306,36 @@ mod tests {
275306
let b = bits(24, &[0, 1, 16]);
276307
assert!(!bits_is_subset(&a, &b));
277308
}
309+
310+
/// Guard the three SSZ offsets at fixed byte positions so a one-off in any
311+
/// constant doesn't silently produce a blob that still has the right outer
312+
/// length but decodes incorrectly at the inner `Signature` container level.
313+
#[test]
314+
fn blank_xmss_signature_has_expected_ssz_offsets() {
315+
let sig = blank_xmss_signature();
316+
let bytes: Vec<u8> = sig.into_iter().collect();
317+
318+
assert_eq!(bytes.len(), SIGNATURE_SIZE);
319+
assert_eq!(
320+
u32::from_le_bytes(bytes[0..4].try_into().unwrap()),
321+
SIGNATURE_PATH_OFFSET,
322+
);
323+
assert_eq!(
324+
u32::from_le_bytes(bytes[32..36].try_into().unwrap()),
325+
SIGNATURE_HASHES_OFFSET,
326+
);
327+
assert_eq!(
328+
u32::from_le_bytes(bytes[36..40].try_into().unwrap()),
329+
SIGNATURE_PATH_SIBLINGS_OFFSET,
330+
);
331+
332+
// Everything outside the three offset slots must be zero — the
333+
// placeholder is "all-zero hashes" once the offsets locate them.
334+
for (i, b) in bytes.iter().enumerate() {
335+
let in_offset_slot = matches!(i, 0..4 | 32..36 | 36..40);
336+
if !in_offset_slot {
337+
assert_eq!(*b, 0, "non-offset byte at index {i} should be zero");
338+
}
339+
}
340+
}
278341
}

crates/net/p2p/src/req_resp/handlers.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -400,9 +400,8 @@ mod tests {
400400
use super::*;
401401
use ethlambda_storage::{ForkCheckpoints, backend::InMemoryBackend};
402402
use ethlambda_types::{
403-
attestation::XmssSignature,
403+
attestation::blank_xmss_signature,
404404
block::{Block, BlockBody, BlockSignatures},
405-
signature::SIGNATURE_SIZE,
406405
state::State,
407406
};
408407
use libssz_types::SszList;
@@ -419,7 +418,7 @@ mod tests {
419418
},
420419
signature: BlockSignatures {
421420
attestation_signatures: SszList::new(),
422-
proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap(),
421+
proposer_signature: blank_xmss_signature(),
423422
},
424423
}
425424
}

crates/net/rpc/src/lib.rs

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -494,11 +494,10 @@ mod tests {
494494
#[tokio::test]
495495
async fn test_get_latest_finalized_block() {
496496
use ethlambda_types::{
497-
attestation::XmssSignature,
497+
attestation::blank_xmss_signature,
498498
block::{Block, BlockBody, BlockSignatures, SignedBlock},
499499
checkpoint::Checkpoint,
500500
primitives::{H256, HashTreeRoot as _},
501-
signature::SIGNATURE_SIZE,
502501
};
503502
use libssz::SszEncode;
504503

@@ -519,7 +518,7 @@ mod tests {
519518
message: block,
520519
signature: BlockSignatures {
521520
attestation_signatures: Default::default(),
522-
proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap(),
521+
proposer_signature: blank_xmss_signature(),
523522
},
524523
};
525524

@@ -559,14 +558,37 @@ mod tests {
559558
}
560559

561560
#[tokio::test]
562-
async fn test_get_latest_finalized_block_returns_404_when_absent() {
563-
// Genesis-anchored store: init_store writes header + state but no
564-
// BlockSignatures entry, so get_signed_block(genesis_root) returns None
565-
// and the endpoint must report 404 rather than panic.
561+
async fn test_get_latest_finalized_block_serves_genesis_with_placeholder_signature() {
562+
use ethlambda_types::{
563+
attestation::blank_xmss_signature,
564+
block::{BlockSignatures, SignedBlock},
565+
};
566+
use libssz::SszEncode;
567+
568+
// Genesis-anchored store: `init_store` writes the header + state but no
569+
// `BlockSignatures` row. `get_signed_block` synthesizes an empty
570+
// `BlockSignatures` so peers can still receive the genesis block on
571+
// BlocksByRoot; the HTTP endpoint stays consistent and returns 200
572+
// rather than 404.
566573
let state = create_test_state();
567574
let backend = Arc::new(InMemoryBackend::new());
568575
let store = Store::from_anchor_state(backend, state);
569576

577+
// The body the endpoint serves must round-trip to a `SignedBlock`
578+
// matching the genesis header paired with the synthetic blank
579+
// signatures — same shape `get_signed_block` builds in storage.
580+
let genesis_block = store
581+
.get_signed_block(&store.latest_finalized().root)
582+
.expect("genesis served via get_signed_block");
583+
let expected = SignedBlock {
584+
message: genesis_block.message.clone(),
585+
signature: BlockSignatures {
586+
attestation_signatures: Default::default(),
587+
proposer_signature: blank_xmss_signature(),
588+
},
589+
};
590+
let expected_ssz = expected.to_ssz();
591+
570592
let app = build_api_router(store);
571593

572594
let response = app
@@ -579,6 +601,13 @@ mod tests {
579601
.await
580602
.unwrap();
581603

582-
assert_eq!(response.status(), StatusCode::NOT_FOUND);
604+
assert_eq!(response.status(), StatusCode::OK);
605+
assert_eq!(
606+
response.headers().get(header::CONTENT_TYPE).unwrap(),
607+
SSZ_CONTENT_TYPE
608+
);
609+
610+
let body = response.into_body().collect().await.unwrap().to_bytes();
611+
assert_eq!(body.as_ref(), expected_ssz.as_slice());
583612
}
584613
}

crates/storage/src/store.rs

Lines changed: 91 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
11
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
22
use std::sync::{Arc, LazyLock, Mutex};
33

4-
/// The tree hash root of an empty block body.
5-
///
6-
/// Used to detect genesis/anchor blocks that have no attestations,
7-
/// allowing us to skip storing empty bodies and reconstruct them on read.
8-
static EMPTY_BODY_ROOT: LazyLock<H256> = LazyLock::new(|| BlockBody::default().hash_tree_root());
9-
104
use crate::api::{StorageBackend, StorageWriteBatch, Table};
115

126
use ethlambda_types::{
13-
attestation::{AttestationData, HashedAttestationData, bits_is_subset},
7+
attestation::{AttestationData, HashedAttestationData, bits_is_subset, blank_xmss_signature},
148
block::{
15-
AggregatedSignatureProof, Block, BlockBody, BlockHeader, BlockSignatures, SignedBlock,
9+
AggregatedSignatureProof, AttestationSignatures, Block, BlockBody, BlockHeader,
10+
BlockSignatures, SignedBlock,
1611
},
1712
checkpoint::Checkpoint,
1813
primitives::{H256, HashTreeRoot as _},
@@ -36,6 +31,24 @@ pub enum GetForkchoiceStoreError {
3631
},
3732
}
3833

34+
/// The tree hash root of an empty block body.
35+
///
36+
/// Used to detect genesis/anchor blocks that have no attestations,
37+
/// allowing us to skip storing empty bodies and reconstruct them on read.
38+
static EMPTY_BODY_ROOT: LazyLock<H256> = LazyLock::new(|| BlockBody::default().hash_tree_root());
39+
40+
/// Build a placeholder `BlockSignatures` for blocks that were never signed.
41+
///
42+
/// Genesis-style anchor blocks have no proposer signature and no per-attestation
43+
/// proofs (no attestations exist). `get_signed_block` returns this so peers can
44+
/// still receive the block in BlocksByRoot responses.
45+
fn empty_block_signatures() -> BlockSignatures {
46+
BlockSignatures {
47+
attestation_signatures: AttestationSignatures::default(),
48+
proposer_signature: blank_xmss_signature(),
49+
}
50+
}
51+
3952
/// Checkpoints to update in the forkchoice store.
4053
///
4154
/// Used with `Store::update_checkpoints` to update head and optionally
@@ -990,15 +1003,21 @@ impl Store {
9901003

9911004
/// Get a signed block by combining header, body, and signatures.
9921005
///
993-
/// Returns None if any of the components are not found.
994-
/// Note: Genesis block has no entry in BlockSignatures table.
1006+
/// Returns None if the header or body (for non-empty bodies) is missing,
1007+
/// or if the signature row is missing for any block other than the
1008+
/// slot-0 anchor.
1009+
///
1010+
/// Signatures are absent for genesis-style anchor blocks (no proposer
1011+
/// ever signed them). To keep BlocksByRoot symmetric with the
1012+
/// fork-choice view for peers, synthesize empty `BlockSignatures` for
1013+
/// the slot-0 case only; for any other slot the missing-signature
1014+
/// state is treated as storage corruption and surfaces as `None`
1015+
/// rather than as a fabricated block.
9951016
pub fn get_signed_block(&self, root: &H256) -> Option<SignedBlock> {
9961017
let view = self.backend.begin_read().expect("read view");
9971018
let key = root.to_ssz();
9981019

9991020
let header_bytes = view.get(Table::BlockHeaders, &key).expect("get")?;
1000-
let sig_bytes = view.get(Table::BlockSignatures, &key).expect("get")?;
1001-
10021021
let header = BlockHeader::from_ssz_bytes(&header_bytes).expect("valid header");
10031022

10041023
// Use empty body if header indicates empty, otherwise fetch from DB
@@ -1009,8 +1028,19 @@ impl Store {
10091028
BlockBody::from_ssz_bytes(&body_bytes).expect("valid body")
10101029
};
10111030

1031+
let signature = match view.get(Table::BlockSignatures, &key).expect("get") {
1032+
Some(sig_bytes) => {
1033+
BlockSignatures::from_ssz_bytes(&sig_bytes).expect("valid signatures")
1034+
}
1035+
// Synthesis only covers the genesis-style anchor (slot 0). Any other
1036+
// missing-signature case is a storage corruption that should surface
1037+
// as `None` rather than fabricating a block whose `attestation_signatures`
1038+
// list is empty regardless of what the body actually carries.
1039+
None if header.slot == 0 => empty_block_signatures(),
1040+
None => return None,
1041+
};
1042+
10121043
let block = Block::from_header_and_body(header, body);
1013-
let signature = BlockSignatures::from_ssz_bytes(&sig_bytes).expect("valid signatures");
10141044

10151045
Some(SignedBlock {
10161046
message: block,
@@ -2313,4 +2343,52 @@ mod tests {
23132343
assert_eq!(buf.total_signatures(), 2); // slot 2 (1) + slot 3 (1)
23142344
assert_eq!(buf.len(), 2);
23152345
}
2346+
2347+
/// `Store::from_anchor_state` writes the header but no `BlockSignatures`
2348+
/// row for the slot-0 anchor. `get_signed_block` must synthesize an empty
2349+
/// `BlockSignatures` so the genesis block can still be served on
2350+
/// BlocksByRoot / `/lean/v0/blocks/finalized`.
2351+
#[test]
2352+
fn get_signed_block_synthesizes_blank_signatures_for_genesis_anchor() {
2353+
let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::new());
2354+
let store = Store::from_anchor_state(backend, State::from_genesis(0, vec![]));
2355+
2356+
let head_root = store.head();
2357+
let signed = store
2358+
.get_signed_block(&head_root)
2359+
.expect("genesis block must be retrievable with synthetic signatures");
2360+
2361+
assert_eq!(signed.message.slot, 0);
2362+
assert_eq!(signed.signature.proposer_signature, blank_xmss_signature());
2363+
assert_eq!(signed.signature.attestation_signatures.len(), 0);
2364+
}
2365+
2366+
/// The synthesis branch must be confined to the slot-0 anchor: a
2367+
/// non-genesis block whose `BlockSignatures` row is missing is treated
2368+
/// as storage corruption and surfaces as `None`, not a fabricated block.
2369+
#[test]
2370+
fn get_signed_block_returns_none_for_non_genesis_with_missing_signatures() {
2371+
let backend: Arc<dyn StorageBackend> = Arc::new(InMemoryBackend::new());
2372+
2373+
// Hand-insert a slot-1 header (and empty body, via `EMPTY_BODY_ROOT`)
2374+
// but skip the `BlockSignatures` row. This mimics the corruption case
2375+
// the guard is meant to catch, without going through the normal
2376+
// `insert_signed_block` write path which always writes all three rows.
2377+
let header = BlockHeader {
2378+
slot: 1,
2379+
proposer_index: 0,
2380+
parent_root: H256::ZERO,
2381+
state_root: H256::ZERO,
2382+
body_root: *EMPTY_BODY_ROOT,
2383+
};
2384+
let root = header.hash_tree_root();
2385+
let mut batch = backend.begin_write().expect("write batch");
2386+
batch
2387+
.put_batch(Table::BlockHeaders, vec![(root.to_ssz(), header.to_ssz())])
2388+
.expect("put header");
2389+
batch.commit().expect("commit");
2390+
2391+
let store = Store::from_anchor_state(backend, State::from_genesis(0, vec![]));
2392+
assert!(store.get_signed_block(&root).is_none());
2393+
}
23162394
}

0 commit comments

Comments
 (0)