Skip to content

fix: serve genesis block on BlocksByRoot with structurally-valid blank XMSS signature#371

Open
MegaRedHand wants to merge 2 commits into
mainfrom
fix/genesis-blank-xmss-signature
Open

fix: serve genesis block on BlocksByRoot with structurally-valid blank XMSS signature#371
MegaRedHand wants to merge 2 commits into
mainfrom
fix/genesis-blank-xmss-signature

Conversation

@MegaRedHand
Copy link
Copy Markdown
Collaborator

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()` (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

  • `make fmt`
  • `make lint` (`-D warnings`)
  • `cargo test --workspace` — all suites green locally
  • 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` 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.

`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.
`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).
@github-actions
Copy link
Copy Markdown

🤖 Claude Code Review

PR #371 — fix: serve genesis block on BlocksByRoot with structurally-valid blank XMSS signature

Overview

Two coupled fixes: (1) get_signed_block now synthesizes empty BlockSignatures for genesis/anchor blocks rather than returning None, and (2) the placeholder XMSS signature is now SSZ-structurally valid (matching ream's Signature::blank()), with all call sites consolidated to a single blank_xmss_signature() helper. The PR is well-documented and the math checks out.


Correctness

SSZ offset math — verified correct:

  • bytes[0..4] = path offset = 36 → 4-byte path_offset + 28-byte rho + 4-byte hashes_offset = 36 ✓
  • bytes[32..36] = hashes offset = 1064 → path body at 36, size = 4 + 32×32 = 1028, so hashes at 1064 ✓
  • bytes[36..40] = siblings offset = 4 → first field inside path body, body immediately follows its own 4-byte offset ✓
  • Total: 36 + 1028 + 46×32 = 36 + 1028 + 1472 = 2536 = SIGNATURE_SIZE

The stale (3112 bytes) doc removal is also correct; 2536 is DIM=46 layout.


Potential Issues

1. Synthesis applies to ALL blocks with missing BlockSignatures, not just genesis

storage/src/store.rs:1002–1008

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(),
};

CLAUDE.md states: "All other blocks must have entries in all three tables." If a non-genesis block somehow has a missing BlockSignatures row (storage bug, partial write, etc.), this silently serves a structurally valid but semantically wrong block — zero proposer signature, zero attestations — rather than returning None. A consumer receiving this via BlocksByRoot would not know the block is corrupt.

Suggestion: Guard the synthesis on the anchor/genesis block specifically, e.g. check header.slot == 0 or compare root against a stored anchor root, and return None for the unexpected missing-signature case otherwise:

None if header.slot == 0 => empty_block_signatures(),
None => return None,  // or log a warning

This makes the invariant explicit and avoids masking potential storage corruption.

2. empty_block_signatures() is defined before its use imports in store.rs

The diff inserts the function above the existing use crate::api::... block, meaning BlockSignatures, AttestationSignatures, and blank_xmss_signature are imported after the function that uses them. Rust compiles this correctly (items in a module are order-independent), but it looks wrong on a first read and is inconsistent with the rest of the file. Moving the function after the use block (or to the bottom of the free-function section before impl Store) would follow the file's existing layout.

3. No direct unit test for blank_xmss_signature() byte layout

The SSZ offset logic is subtle — a one-off in any constant silently produces a blob that looks valid at the outer-container level but decodes incorrectly at the inner-Signature level. The hive suite exercises it end-to-end, but a small unit test that reads the three offset values back out of the returned bytes would catch regressions without requiring a full devnet run:

#[test]
fn blank_xmss_signature_has_valid_ssz_offsets() {
    let sig = blank_xmss_signature();
    let b = sig.as_ssz_bytes();
    assert_eq!(u32::from_le_bytes(b[0..4].try_into().unwrap()), 36);
    assert_eq!(u32::from_le_bytes(b[32..36].try_into().unwrap()), 1064);
    assert_eq!(u32::from_le_bytes(b[36..40].try_into().unwrap()), 4);
}

4. No unit test for get_signed_block with a genesis root

The synthesis path is tested indirectly via the RPC test_get_latest_finalized_block_serves_genesis_with_placeholder_signature test. A direct test in crates/storage/src/store.rs (alongside the existing block round-trip tests) would give coverage closer to the changed code:

// store round-trip: genesis block has no BlockSignatures row, synthesis returns Some
let signed = store.get_signed_block(&genesis_root);
assert!(signed.is_some());
assert_eq!(signed.unwrap().signature.proposer_signature, blank_xmss_signature());

Minor / Style

  • The expect("size matches SIGNATURE_SIZE") in blank_xmss_signature() is correct — the size is statically guaranteed by the vec![0u8; SIGNATURE_SIZE] construction. No concern.
  • All existing XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap() call sites are correctly unified to blank_xmss_signature(). The consolidation is clean.
  • The renamed test test_get_latest_finalized_block_serves_genesis_with_placeholder_signature is more descriptive than the removed _returns_404_when_absent — good change.

Summary

The wire-format math is correct and the consolidation to blank_xmss_signature() is a clear improvement. The main thing to address before merging is Item 1 (the None => empty_block_signatures() fallback is too broad and could mask storage corruption for non-genesis blocks). Items 3 and 4 are nice-to-haves that would make the invariant more robust to future refactors. Item 2 is cosmetic.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@github-actions
Copy link
Copy Markdown

🤖 Kimi Code Review

Overall Assessment: Good change that consolidates genesis signature handling into a single, well-documented helper. The structurally-valid SSZ approach for placeholder signatures is safer than opaque zero-bytes. However, there are correctness, performance, and maintainability issues to address.


Critical Issues

None identified. The blank signature correctly uses zeroed cryptographic material that will fail verification against any real public key, which is the required behavior for unsignable genesis blocks.


Bugs & Correctness

1. Incorrect documentation of hash array size

  • File: crates/common/types/src/attestation.rs
  • Line: 78 (comment)
  • Issue: The doc comment states hashes = [0; 46], but the offset math (SIGNATURE_SIZE 3112 − 1064 = 2048; 2048 ÷ 32 = 64) implies 64 hashes. If the XMSS parameter set actually uses 46, the offsets are wrong; if it uses 64, the comment is wrong.
  • Fix: Verify the XMSS height/WOTS+ parameters and correct either the offsets or the comment. Add a debug assertion that SIGNATURE_HASHES_OFFSET + (NUM_HASHES * 32) == SIGNATURE_SIZE.

**


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 14, 2026

Greptile Summary

This PR fixes two related gaps that prevented the genesis block from being served via BlocksByRoot and /lean/v0/blocks/finalized. First, get_signed_block in storage now synthesizes an empty BlockSignatures when the header exists but no signature row was written, rather than returning None. Second, the placeholder proposer signature is upgraded from raw zero bytes to a structurally valid SSZ Signature blob matching ream's Signature::blank(), so consumers that round-trip the inner container can decode it correctly.

  • blank_xmss_signature() centralises the placeholder construction with correct SSZ offsets (36 / 1064 / 4); the arithmetic is verified and byte-identical to ream's implementation.
  • All previous XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]) call sites across blockchain, p2p, rpc, and fork-choice fixtures are migrated to the new helper.
  • The HTTP test that previously asserted 404 on a genesis-anchored store is updated to assert 200, keeping it consistent with the BlocksByRoot path.

Confidence Score: 4/5

Safe to merge; the wire format for already-signed blocks is unchanged and the genesis placeholder now matches ream's blank() byte-for-byte.

The SSZ offset arithmetic in blank_xmss_signature() is correct and the storage change is narrowly scoped. Two non-blocking observations: the get_signed_block fallback silently covers any block with a missing signature row rather than enforcing the genesis-only invariant structurally, and the new RPC test stops at a status-code check without verifying the response body.

crates/storage/src/store.rs (broadened fallback) and crates/net/rpc/src/lib.rs (incomplete test assertions on the genesis path)

Important Files Changed

Filename Overview
crates/common/types/src/attestation.rs Introduces blank_xmss_signature() with hardcoded SSZ offsets (36, 1064, 4); math checks out but offset correctness relies on spec constants remaining stable.
crates/storage/src/store.rs get_signed_block now synthesizes empty BlockSignatures for any block missing a signature row, not just genesis; intentional but broadens fallback scope beyond the documented case.
crates/net/rpc/src/lib.rs Genesis test updated from 404 to 200; new test only asserts status code, skipping body/content-type checks present in the neighbouring test.
crates/blockchain/src/store.rs Test stubs migrated from inline zero-filled vec to blank_xmss_signature(); straightforward call-site update with no logic changes.
crates/common/test-fixtures/src/fork_choice.rs Migrates fork-choice spec-test fixture builder to blank_xmss_signature(); trivially correct.
crates/net/p2p/src/req_resp/handlers.rs Test helper migrated to blank_xmss_signature(); no logic changes, clean substitution.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["BlocksByRoot / GET /lean/v0/blocks/finalized"] --> B["get_signed_block(root)"]
    B --> C{"BlockHeaders row?"}
    C -- No --> D["return None → 404"]
    C -- Yes --> E{"BlockBodies row?\n(if non-empty body)"}
    E -- No --> D
    E -- Yes --> F{"BlockSignatures row?"}
    F -- Yes --> G["Deserialize real BlockSignatures"]
    F -- No --> H["synthesize empty_block_signatures()\nblank_xmss_signature() + empty attestations"]
    G --> I["Assemble SignedBlock"]
    H --> I
    I --> J["Serialize to SSZ → 200 OK"]
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
crates/storage/src/store.rs:1005-1010
**Fallback applies to any block missing signatures, not just genesis**

The synthesized-signature path triggers whenever `Table::BlockSignatures` has no row for a given root — including non-genesis blocks whose signature write was skipped or failed silently. The doc comment scopes the intent to "genesis-style anchor blocks," but the code has no guard (e.g., `header.slot == 0`) to enforce that. A non-genesis block with a missing signature row would now be served to peers with a blank XMSS placeholder rather than being treated as absent, making the gap invisible to callers. In the current write path this can't happen, but the invariant is implicit rather than structural.

### Issue 2 of 2
crates/net/rpc/src/lib.rs:560-584
**New genesis test only asserts status code**

`test_get_latest_finalized_block_serves_genesis_with_placeholder_signature` verifies `StatusCode::OK` but does not check the response body or `Content-Type` header. The adjacent test (`test_get_latest_finalized_block`) asserts both, and their symmetry is the best guard that the serialisation path actually works end-to-end for the genesis case. Without a body check, a regression that returns 200 with empty or malformed SSZ bytes would pass this test silently.

Reviews (1): Last reviewed commit: "fix(types): use SSZ-structurally-valid b..." | Re-trigger Greptile

Comment on lines +1005 to +1010
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(),
};
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.

P2 Fallback applies to any block missing signatures, not just genesis

The synthesized-signature path triggers whenever Table::BlockSignatures has no row for a given root — including non-genesis blocks whose signature write was skipped or failed silently. The doc comment scopes the intent to "genesis-style anchor blocks," but the code has no guard (e.g., header.slot == 0) to enforce that. A non-genesis block with a missing signature row would now be served to peers with a blank XMSS placeholder rather than being treated as absent, making the gap invisible to callers. In the current write path this can't happen, but the invariant is implicit rather than structural.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/storage/src/store.rs
Line: 1005-1010

Comment:
**Fallback applies to any block missing signatures, not just genesis**

The synthesized-signature path triggers whenever `Table::BlockSignatures` has no row for a given root — including non-genesis blocks whose signature write was skipped or failed silently. The doc comment scopes the intent to "genesis-style anchor blocks," but the code has no guard (e.g., `header.slot == 0`) to enforce that. A non-genesis block with a missing signature row would now be served to peers with a blank XMSS placeholder rather than being treated as absent, making the gap invisible to callers. In the current write path this can't happen, but the invariant is implicit rather than structural.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread crates/net/rpc/src/lib.rs
Comment on lines 560 to 584
@@ -579,6 +580,6 @@ mod tests {
.await
.unwrap();

assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(response.status(), StatusCode::OK);
}
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.

P2 New genesis test only asserts status code

test_get_latest_finalized_block_serves_genesis_with_placeholder_signature verifies StatusCode::OK but does not check the response body or Content-Type header. The adjacent test (test_get_latest_finalized_block) asserts both, and their symmetry is the best guard that the serialisation path actually works end-to-end for the genesis case. Without a body check, a regression that returns 200 with empty or malformed SSZ bytes would pass this test silently.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/net/rpc/src/lib.rs
Line: 560-584

Comment:
**New genesis test only asserts status code**

`test_get_latest_finalized_block_serves_genesis_with_placeholder_signature` verifies `StatusCode::OK` but does not check the response body or `Content-Type` header. The adjacent test (`test_get_latest_finalized_block`) asserts both, and their symmetry is the best guard that the serialisation path actually works end-to-end for the genesis case. Without a body check, a regression that returns 200 with empty or malformed SSZ bytes would pass this test silently.

How can I resolve this? If you propose a fix, please make it concise.

@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

  1. crates/storage/src/store.rs: get_signed_block() now fabricates BlockSignatures for any missing BlockSignatures row. That is broader than the stated “genesis-style anchor” case: it also hides ordinary DB corruption / partial writes, and it can return a malformed SignedBlock for any block that has attestations but no signature row because empty_block_signatures() always sets attestation_signatures to empty. Callers in crates/net/p2p/src/req_resp/handlers.rs, crates/net/rpc/src/lib.rs, and crates/blockchain/src/lib.rs will now treat that fabricated block as real. I’d keep get_signed_block() strict and special-case only the actual slot-0 anchor, or persist the placeholder row at initialization instead of weakening the read path.

  2. crates/net/rpc/src/lib.rs with crates/storage/src/store.rs: the new 200 OK genesis path serves a SignedBlock whose synthetic proposer signature will fail the normal verifier in crates/blockchain/src/store.rs. So the RPC/P2P surface can now emit a “signed” block that this client itself would reject on import. If slot-0 retrieval is required, that contract should be explicit and narrowly scoped; otherwise the previous “not found” behavior was safer than advertising an unverifiable signed block.

Static review only. I couldn’t run the targeted cargo tests here because the environment blocks writes under ~/.rustup / ~/.cargo and dependency fetches.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

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.

1 participant