From eff6b8b17eae6c45c78103f6c62b90281eaface6 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, 24 Jun 2026 17:46:13 -0300 Subject: [PATCH] feat(types): carry proposer signature outside the block proof Split `SignedBlock.proof` into `BlockProof { proposer_signature, attestation_proof }`. The proposer's raw XMSS signature is now carried as a standalone field and verified directly with the hash-based XMSS verifier, while `attestation_proof` is a lean-multisig Type-2 over the block body's attestations only. Previously the proposer signature was wrapped as a singleton Type-1 and merged into a single block Type-2 alongside every attestation, so even a block with zero attestations needed a prover call. Decoupling the proposer lets the attestation aggregate be built independently of the block root (a prerequisite for proposer prebuild) and removes prover work from the empty-attestation case. before: block-proof = aggregate([prop-sig, att0, att1]) after: block-proof = (prop-sig, aggregate([att0, att1])) before (no atts): aggregate([prop-sig]) after (no atts): (prop-sig, empty-proof) Verification now checks the raw proposer signature against the proposer's proposal pubkey, then verifies the attestation Type-2 over attestation components only (and rejects a stray aggregate on an attestation-less block). Reaggregation drops the proposer component from the split layout. NOTE: this diverges from the leanSpec #799 single-merged-proof wire format, so the signature/SSZ spec tests fail against the current cross-client fixtures until those are regenerated for the new layout. Draft PR. --- crates/blockchain/src/block_builder.rs | 21 ++- crates/blockchain/src/lib.rs | 136 +++++++--------- crates/blockchain/src/reaggregate.rs | 21 +-- crates/blockchain/src/store.rs | 132 +++++++++------- .../common/test-fixtures/src/fork_choice.rs | 4 +- .../test-fixtures/src/verify_signatures.rs | 13 +- crates/common/types/src/block.rs | 147 ++++++++++++++++-- crates/net/p2p/src/req_resp/handlers.rs | 4 +- crates/net/rpc/src/lib.rs | 8 +- crates/storage/src/store.rs | 10 +- 10 files changed, 313 insertions(+), 183 deletions(-) diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index 722d98d7..0f2f1353 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -711,8 +711,12 @@ fn trace_skipped_attestation(reason: &'static str, att: &AttestationData, data_r mod tests { use super::*; use ethlambda_types::{ - attestation::{AggregatedAttestation, AggregationBits, AttestationData}, - block::{ByteList512KiB, MultiMessageAggregate, SignedBlock, TypeOneMultiSignature}, + attestation::{ + AggregatedAttestation, AggregationBits, AttestationData, blank_xmss_signature, + }, + block::{ + BlockProof, ByteList512KiB, MultiMessageAggregate, SignedBlock, TypeOneMultiSignature, + }, checkpoint::Checkpoint, state::State, }; @@ -917,11 +921,16 @@ mod tests { ); // Substitute a worst-case-size proof to model what `propose_block` - // would attach. The actual SNARK can't be built without lean-multisig, - // but the size cap (`ByteList512KiB`) bounds the worst case. + // would attach: a 512 KiB attestation aggregate plus the fixed-size + // proposer signature. The actual SNARK can't be built without + // lean-multisig, but the size cap bounds the worst case. let _ = signatures; - let proof = MultiMessageAggregate::new( - ByteList512KiB::try_from(vec![0xAB; 512 * 1024]).expect("worst-case proof fits in cap"), + let proof = BlockProof::new( + blank_xmss_signature(), + MultiMessageAggregate::new( + ByteList512KiB::try_from(vec![0xAB; 512 * 1024]) + .expect("worst-case proof fits in cap"), + ), ); let signed_block = SignedBlock { message: block, diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 4360377d..85c065bf 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -8,9 +8,9 @@ use ethlambda_types::{ ShortRoot, aggregator::AggregatorController, attestation::{SignedAggregatedAttestation, SignedAttestation}, - block::{ByteList512KiB, MultiMessageAggregate, SignedBlock}, + block::{BlockProof, ByteList512KiB, MultiMessageAggregate, SignedBlock}, primitives::{H256, HashTreeRoot as _}, - signature::{ValidatorPublicKey, ValidatorSignature}, + signature::ValidatorPublicKey, }; use crate::aggregation::{ @@ -473,103 +473,85 @@ impl BlockChainServer { return; }; - // Assemble SignedBlock: wrap the proposer's raw XMSS signature into a - // singleton Type-1 SNARK, then merge it with every attestation Type-1 - // into the block's single Type-2 proof. + // Assemble SignedBlock: carry the proposer's raw XMSS signature as a + // standalone field, and aggregate the attestation Type-1s (only) into + // the block's attestation Type-2. The proposer no longer enters the + // aggregate, so a block with no attestations needs no prover work and + // the attestation Type-2 can be built independently of the block root. let head_state = self.store.head_state(); let validators = &head_state.validators; - let Some(proposer_validator) = validators.get(validator_id as usize) else { + if validators.get(validator_id as usize).is_none() { error!(%slot, %validator_id, "Proposer index out of range when assembling block"); metrics::inc_block_building_failures(); return; - }; + } - // Decode the proposer's proposal pubkey once and reuse it both for the - // singleton Type-1 wrap and for the Type-2 merge inputs. - let Ok(proposer_pubkey) = proposer_validator.get_proposal_pubkey().inspect_err( - |err| error!(%slot, %validator_id, %err, "Failed to decode proposer proposal pubkey"), - ) else { - metrics::inc_block_building_failures(); - return; - }; + // `sign_block_root` already returns an `XmssSignature`, so the proposer + // signature is carried verbatim — no packing or prover work needed. - let Ok(proposer_validator_signature) = - ValidatorSignature::from_bytes(&proposer_signature).inspect_err(|err| { - error!(%slot, %validator_id, %err, "Failed to decode proposer signature bytes") - }) - else { - metrics::inc_block_building_failures(); - return; - }; - let Ok(proposer_proof_bytes) = ethlambda_crypto::aggregate_signatures( - vec![proposer_pubkey.clone()], - vec![proposer_validator_signature], - &block_root, - slot as u32, - ) - .inspect_err( - |err| error!(%slot, %validator_id, %err, "Failed to wrap proposer signature as Type-1"), - ) else { - metrics::inc_block_building_failures(); - return; - }; - - let mut merge_inputs: Vec<(Vec, ByteList512KiB)> = - Vec::with_capacity(type_one_proofs.len() + 1); - let mut resolve_failed = false; - for t1 in &type_one_proofs { - let mut pubkeys = Vec::new(); - for vid in t1.participant_indices() { - let Some(validator) = validators.get(vid as usize) else { - error!(%slot, %validator_id, vid, "Participant out of range while resolving pubkeys"); - resolve_failed = true; - break; - }; - match validator.get_attestation_pubkey() { - Ok(pk) => pubkeys.push(pk), - Err(err) => { - error!(%slot, %validator_id, vid, %err, "Failed to decode attestation pubkey"); + // Aggregate the attestation Type-1s into a single Type-2. With no + // attestations the aggregate is empty: the proposer signature stands + // alone, mirroring `(prop-sig, empty-proof)`. + let attestation_proof = if type_one_proofs.is_empty() { + MultiMessageAggregate::default() + } else { + let mut merge_inputs: Vec<(Vec, ByteList512KiB)> = + Vec::with_capacity(type_one_proofs.len()); + let mut resolve_failed = false; + for t1 in &type_one_proofs { + let mut pubkeys = Vec::new(); + for vid in t1.participant_indices() { + let Some(validator) = validators.get(vid as usize) else { + error!(%slot, %validator_id, vid, "Participant out of range while resolving pubkeys"); resolve_failed = true; break; + }; + match validator.get_attestation_pubkey() { + Ok(pk) => pubkeys.push(pk), + Err(err) => { + error!(%slot, %validator_id, vid, %err, "Failed to decode attestation pubkey"); + resolve_failed = true; + break; + } } } + if resolve_failed { + break; + } + merge_inputs.push((pubkeys, t1.proof.clone())); } if resolve_failed { - break; - } - merge_inputs.push((pubkeys, t1.proof.clone())); - } - if resolve_failed { - metrics::inc_block_building_failures(); - return; - } - merge_inputs.push((vec![proposer_pubkey], proposer_proof_bytes)); - - // Merge yields raw lean-multisig Type-2 bytes. Per-component - // participants are rederived at verify time from - // `block.body.attestations[i].aggregation_bits` plus - // `block.proposer_index`, so nothing else needs persisting. - let merged_bytes = match ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) { - Ok(bytes) => bytes, - Err(err) => { - error!(%slot, %validator_id, %err, "Failed to merge Type-1s into Type-2"); metrics::inc_block_building_failures(); return; } - }; - let proof = match MultiMessageAggregate::from_bytes(merged_bytes.iter().as_slice()) { - Ok(p) => p, - Err(err) => { - error!(%slot, %validator_id, %err, "Failed to build multi-message aggregate"); - metrics::inc_block_building_failures(); - return; + + // Merge yields raw lean-multisig Type-2 bytes. Per-component + // participants are rederived at verify time from + // `block.body.attestations[i].aggregation_bits`, so nothing else + // needs persisting. + let merged_bytes = match ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) { + Ok(bytes) => bytes, + Err(err) => { + error!(%slot, %validator_id, %err, "Failed to merge Type-1s into Type-2"); + metrics::inc_block_building_failures(); + return; + } + }; + match MultiMessageAggregate::from_bytes(merged_bytes.iter().as_slice()) { + Ok(p) => p, + Err(err) => { + error!(%slot, %validator_id, %err, "Failed to build multi-message aggregate"); + metrics::inc_block_building_failures(); + return; + } } }; + // `type_one_proofs` is no longer needed past this point. drop(type_one_proofs); let signed_block = SignedBlock { message: block, - proof, + proof: BlockProof::new(proposer_signature, attestation_proof), }; // Process the block locally before publishing diff --git a/crates/blockchain/src/reaggregate.rs b/crates/blockchain/src/reaggregate.rs index 53275fac..4988303f 100644 --- a/crates/blockchain/src/reaggregate.rs +++ b/crates/blockchain/src/reaggregate.rs @@ -70,11 +70,12 @@ pub fn reaggregate_from_block( let validators = &parent_state.validators; let num_validators = validators.len() as u64; - // Per-component pubkeys: one entry per body attestation in order, then - // the proposer entry. Layout is invariant per block, so it's resolved - // once and reused for every split call below. + // Per-component pubkeys: one entry per body attestation in order. The + // attestation aggregate no longer carries a proposer component (the + // proposer signature lives outside it), so the layout is attestations + // only. Resolved once and reused for every split call below. let mut pubkeys_per_component: Vec> = - Vec::with_capacity(attestations.len() + 1); + Vec::with_capacity(attestations.len()); for att in &attestations { let mut pubkeys = Vec::new(); for vid in validator_indices(&att.aggregation_bits) { @@ -90,14 +91,6 @@ pub fn reaggregate_from_block( } pubkeys_per_component.push(pubkeys); } - if block.proposer_index >= num_validators { - return Vec::new(); - } - let Ok(proposer_pubkey) = validators[block.proposer_index as usize].get_proposal_pubkey() - else { - return Vec::new(); - }; - pubkeys_per_component.push(vec![proposer_pubkey]); let candidates = select_candidates(store, &attestations); if candidates.is_empty() { @@ -119,8 +112,8 @@ pub fn reaggregate_from_block( }; // Step 1: SNARK-split this attestation's component out of the block's - // merged Type-2 proof. - let merged_bytes = signed_block.proof.proof_bytes(); + // attestation Type-2 aggregate. + let merged_bytes = signed_block.proof.attestation_proof.proof_bytes(); let split_bytes = match ethlambda_crypto::split_type_2_by_message( merged_bytes, pubkeys_per_component.clone(), diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 8f7807d2..bbcfd053 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -848,6 +848,9 @@ pub enum StoreError { #[error("Validator signature verification failed")] SignatureVerificationFailed, + #[error("Block carries an attestation proof but has no attestations")] + UnexpectedAttestationProof, + #[error("Block slot {0} exceeds u32 range")] SlotOutOfRange(u64), @@ -941,13 +944,18 @@ pub enum StoreError { }, } -/// Full verification of a signed block's merged Type-2 proof. +/// Full verification of a signed block's proof. +/// +/// The proof has two independent parts: /// -/// Structural pre-checks (fast fail) ensure the merged proof's `info` list lines -/// up with the block body (one entry per attestation plus a trailing proposer -/// entry; messages, slots, and participants match what the body declares). -/// On success, the lean-multisig devnet5 `verify_type_2` primitive runs the -/// SNARK verifier over the merged proof bytes against the resolved pubkey set. +/// 1. The proposer's raw XMSS signature over the block root, verified directly +/// against the proposer's `proposal_pubkey` with the hash-based verifier. +/// 2. The attestation aggregate: a lean-multisig Type-2 over the body +/// attestations only. Structural pre-checks (fast fail) ensure its `info` +/// list lines up with the block body (one entry per attestation; messages, +/// slots, and participants match what the body declares), then the +/// `verify_type_2` SNARK verifier runs over the proof bytes. A block with no +/// attestations carries no aggregate. /// /// Exposed publicly so RPC handlers (notably the Hive test-driver /// `verify_signatures/run` endpoint) can run the exact same verification path @@ -983,52 +991,70 @@ pub fn verify_block_signatures( let block_root = block.hash_tree_root(); let structural_elapsed = total_start.elapsed(); - // Resolve pubkeys per Type-2 component for verify_type_2 and rederive the - // expected (message, slot) bindings from the block body. Attestation - // components use each participant's attestation_pubkey; the trailing - // proposer component uses the proposal_pubkey of `block.proposer_index`. - let expected_components = attestations.len() + 1; - let mut pubkeys_per_component: Vec> = - Vec::with_capacity(expected_components); - let mut expected_bindings: Vec<(H256, u32)> = Vec::with_capacity(expected_components); - - for attestation in attestations.iter() { - let mut pubkeys = Vec::new(); - for vid in validator_indices(&attestation.aggregation_bits) { - let validator = validators - .get(vid as usize) - .ok_or(StoreError::InvalidValidatorIndex)?; - let pk = validator - .get_attestation_pubkey() - .map_err(|_| StoreError::PubkeyDecodingFailed(vid))?; - pubkeys.push(pk); - } - pubkeys_per_component.push(pubkeys); - let slot_u32 = u32::try_from(attestation.data.slot) - .map_err(|_| StoreError::SlotOutOfRange(attestation.data.slot))?; - expected_bindings.push((attestation.data.hash_tree_root(), slot_u32)); - } + let block_slot_u32 = + u32::try_from(block.slot).map_err(|_| StoreError::SlotOutOfRange(block.slot))?; + // 1. Verify the proposer's raw XMSS signature over the block root. It is + // carried outside the attestation aggregate, so it is checked directly + // against the proposer's proposal pubkey with the hash-based verifier. let proposer_validator = validators .get(block.proposer_index as usize) .ok_or(StoreError::InvalidValidatorIndex)?; let proposer_pubkey = proposer_validator .get_proposal_pubkey() .map_err(|_| StoreError::PubkeyDecodingFailed(block.proposer_index))?; - pubkeys_per_component.push(vec![proposer_pubkey]); - let block_slot_u32 = - u32::try_from(block.slot).map_err(|_| StoreError::SlotOutOfRange(block.slot))?; - expected_bindings.push((block_root, block_slot_u32)); - - let merged_bytes = signed_block.proof.proof_bytes(); + let proposer_signature = ValidatorSignature::from_bytes(&signed_block.proof.proposer_signature) + .map_err(|_| StoreError::SignatureDecodingFailed)?; + if !proposer_signature.is_valid(&proposer_pubkey, block_slot_u32, &block_root) { + return Err(StoreError::SignatureVerificationFailed); + } + // 2. Verify the attestation aggregate (Type-2 over the body attestations + // only). A block with no attestations carries no aggregate; reject a + // stray proof rather than silently ignoring it. let crypto_start = std::time::Instant::now(); - ethlambda_crypto::verify_type_2_signature( - merged_bytes, - pubkeys_per_component, - &expected_bindings, - ) - .map_err(StoreError::AggregateVerificationFailed)?; + if attestations.is_empty() { + if !signed_block + .proof + .attestation_proof + .proof_bytes() + .is_empty() + { + return Err(StoreError::UnexpectedAttestationProof); + } + } else { + // Resolve pubkeys per Type-2 component and rederive the expected + // (message, slot) bindings from the block body. Each component uses its + // participants' attestation_pubkeys. + let mut pubkeys_per_component: Vec> = + Vec::with_capacity(attestations.len()); + let mut expected_bindings: Vec<(H256, u32)> = Vec::with_capacity(attestations.len()); + + for attestation in attestations.iter() { + let mut pubkeys = Vec::new(); + for vid in validator_indices(&attestation.aggregation_bits) { + let validator = validators + .get(vid as usize) + .ok_or(StoreError::InvalidValidatorIndex)?; + let pk = validator + .get_attestation_pubkey() + .map_err(|_| StoreError::PubkeyDecodingFailed(vid))?; + pubkeys.push(pk); + } + pubkeys_per_component.push(pubkeys); + let slot_u32 = u32::try_from(attestation.data.slot) + .map_err(|_| StoreError::SlotOutOfRange(attestation.data.slot))?; + expected_bindings.push((attestation.data.hash_tree_root(), slot_u32)); + } + + let merged_bytes = signed_block.proof.attestation_proof.proof_bytes(); + ethlambda_crypto::verify_type_2_signature( + merged_bytes, + pubkeys_per_component, + &expected_bindings, + ) + .map_err(StoreError::AggregateVerificationFailed)?; + } let crypto_elapsed = crypto_start.elapsed(); let total_elapsed = total_start.elapsed(); @@ -1038,7 +1064,7 @@ pub fn verify_block_signatures( ?structural_elapsed, ?crypto_elapsed, ?total_elapsed, - "Block Type-2 proof verified" + "Block proof verified" ); Ok(()) @@ -1096,24 +1122,24 @@ mod tests { use ethlambda_types::{ attestation::{AggregatedAttestation, AggregationBits, AttestationData}, block::{ - AggregatedAttestations, BlockBody, MultiMessageAggregate, SignedBlock, - TypeOneMultiSignature, + AggregatedAttestations, BlockBody, BlockProof, SignedBlock, TypeOneMultiSignature, }, checkpoint::Checkpoint, state::State, }; - /// Test helper: placeholder block proof bytes. + /// Test helper: placeholder block proof. /// - /// In production the merged proof is the raw `compress_without_pubkeys()` - /// output of `merge_many_type_1`, which can only be built by the - /// lean-multisig prover. Tests that don't go through - /// `verify_block_signatures` use an empty blob. + /// In production the attestation aggregate is the raw + /// `compress_without_pubkeys()` output of `merge_many_type_1`, which can + /// only be built by the lean-multisig prover, and the proposer signature is + /// a real XMSS signature. Tests that don't go through + /// `verify_block_signatures` use an empty proof. fn make_signed_block_proof( _proposer_index: u64, _attestation_proofs: Vec, - ) -> MultiMessageAggregate { - MultiMessageAggregate::default() + ) -> BlockProof { + BlockProof::default() } fn make_bits(indices: &[usize]) -> AggregationBits { diff --git a/crates/common/test-fixtures/src/fork_choice.rs b/crates/common/test-fixtures/src/fork_choice.rs index 1cda6280..e98c2ed5 100644 --- a/crates/common/test-fixtures/src/fork_choice.rs +++ b/crates/common/test-fixtures/src/fork_choice.rs @@ -8,7 +8,7 @@ use crate::{ deser_xmss_hex, }; use ethlambda_types::attestation::XmssSignature; -use ethlambda_types::block::{MultiMessageAggregate, SignedBlock}; +use ethlambda_types::block::{BlockProof, SignedBlock}; use ethlambda_types::primitives::H256; use serde::{Deserialize, Deserializer}; use std::collections::HashMap; @@ -184,7 +184,7 @@ impl BlockStepData { pub fn to_blank_signed_block(&self) -> SignedBlock { SignedBlock { message: self.to_block(), - proof: MultiMessageAggregate::default(), + proof: BlockProof::default(), } } } diff --git a/crates/common/test-fixtures/src/verify_signatures.rs b/crates/common/test-fixtures/src/verify_signatures.rs index 9793ee5c..e96fd3fc 100644 --- a/crates/common/test-fixtures/src/verify_signatures.rs +++ b/crates/common/test-fixtures/src/verify_signatures.rs @@ -11,7 +11,8 @@ //! proof: { proof: { data: "0x" } } use crate::{Block, TestInfo, TestState}; -use ethlambda_types::block::{MultiMessageAggregate, SignedBlock}; +use ethlambda_types::attestation::blank_xmss_signature; +use ethlambda_types::block::{BlockProof, MultiMessageAggregate, SignedBlock}; use serde::Deserialize; use std::collections::HashMap; use std::fmt; @@ -128,17 +129,23 @@ impl TestSignedBlock { /// /// The container carries the raw lean-multisig wire in the /// `MultiMessageAggregate` stored by `SignedBlock.proof`. + /// + /// NOTE: these fixtures use the leanSpec #799 layout (proposer folded into + /// one merged Type-2). This client now carries the proposer signature + /// outside the attestation aggregate, so the merged bytes land in + /// `attestation_proof` with an empty proposer signature. The verify spec + /// tests therefore fail against these fixtures until they are regenerated. pub fn try_into_signed_block_with_proofs(self) -> Result { let bytes = self .proof .decode() .map_err(|err| SignedBlockConvertError::InvalidProofHex(err.to_string()))?; let len = bytes.len(); - let proof = MultiMessageAggregate::from_bytes(&bytes) + let attestation_proof = MultiMessageAggregate::from_bytes(&bytes) .map_err(|_| SignedBlockConvertError::ProofTooLarge(len))?; Ok(SignedBlock { message: self.block.into(), - proof, + proof: BlockProof::new(blank_xmss_signature(), attestation_proof), }) } } diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index 7a71140e..b470abdf 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -4,22 +4,27 @@ use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; use libssz_types::SszList; use crate::{ - attestation::{AggregatedAttestation, AggregationBits, validator_indices}, + attestation::{ + AggregatedAttestation, AggregationBits, XmssSignature, blank_xmss_signature, + validator_indices, + }, primitives::{self, ByteList, H256}, }; // Convenience trait for calling hash_tree_root() without a hasher argument use primitives::HashTreeRoot as _; -/// Envelope carrying a block and the single merged proof binding every -/// signature it depends on. +/// Envelope carrying a block and its [`BlockProof`]. +/// +/// The proof keeps the proposer's raw signature separate from the attestation +/// aggregate (see [`BlockProof`]). /// ///
/// /// `HashTreeRoot` is intentionally not derived: consumers never hash a /// `SignedBlock` directly — they always hash the inner `Block`. Keeping the /// envelope structurally minimal also means the on-chain root is independent -/// of how the merged proof is serialised. +/// of how the proof is serialised. /// ///
#[derive(Clone, SszEncode, SszDecode)] @@ -27,16 +32,23 @@ pub struct SignedBlock { /// The block being signed. pub message: Block, - /// Single full-block proof covering attestations and the proposer signature. - pub proof: MultiMessageAggregate, + /// Full-block proof: proposer signature + attestation aggregate. + pub proof: BlockProof, } -// Manual Debug impl because the merged proof bytes are large and opaque. +// Manual Debug impl because the proof bytes are large and opaque. impl core::fmt::Debug for SignedBlock { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.debug_struct("SignedBlock") .field("message", &self.message) - .field("proof", &format_args!("<{} bytes>", self.proof.proof.len())) + .field( + "proposer_signature", + &format_args!("<{} bytes>", self.proof.proposer_signature.len()), + ) + .field( + "attestation_proof", + &format_args!("<{} bytes>", self.proof.attestation_proof.proof.len()), + ) .finish() } } @@ -84,6 +96,70 @@ pub enum MultiMessageAggregateError { ProofTooLarge(usize), } +// ============================================================================ +// Block proof (proposer signature outside the attestation aggregate) +// ============================================================================ + +/// A full-block proof: the proposer's raw signature plus the attestation +/// aggregate, carried as two independent fields. +/// +/// ```text +/// attestations = [att0, att1] -> (proposer_signature, aggregate([att0, att1])) +/// attestations = [] -> (proposer_signature, empty-proof) +/// ``` +/// +/// `proposer_signature` is the proposer's raw XMSS signature over the block +/// root — the same fixed-size [`XmssSignature`] wire type carried by +/// `SignedAttestation`. It is verified directly against the proposer's +/// `proposal_pubkey` with the hash-based XMSS verifier, so it never enters the +/// lean-multisig prover/verifier. +/// +/// `attestation_proof` is the lean-multisig Type-2 over the block body's +/// attestations *only* — the proposer is no longer one of its components, so +/// it is empty when the block carries no attestations. +/// +///
+/// +/// `HashTreeRoot` is intentionally not derived (as on `SignedAttestation`): +/// `XmssSignature` is a fixed-size byte vector here, but the spec Merkleizes +/// the signature as a container, so a derived root would diverge. Nothing +/// hashes a `BlockProof`. +/// +///
+#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct BlockProof { + /// The proposer's raw XMSS signature over the block root. + pub proposer_signature: XmssSignature, + /// Type-2 aggregate over the body attestations (empty if there are none). + pub attestation_proof: MultiMessageAggregate, +} + +impl BlockProof { + /// Build a proof from a proposer signature and an attestation aggregate. + pub fn new( + proposer_signature: XmssSignature, + attestation_proof: MultiMessageAggregate, + ) -> Self { + Self { + proposer_signature, + attestation_proof, + } + } +} + +impl Default for BlockProof { + /// A blank proof: the structurally-valid all-zero XMSS placeholder used by + /// genesis-style anchor blocks (see [`blank_xmss_signature`]) plus an empty + /// attestation aggregate. `XmssSignature` is fixed-size and has no empty + /// form, so the blank doubles as the genesis placeholder. + fn default() -> Self { + Self { + proposer_signature: blank_xmss_signature(), + attestation_proof: MultiMessageAggregate::default(), + } + } +} + // ============================================================================ // Type-1 multi-signature // ============================================================================ @@ -91,13 +167,12 @@ pub enum MultiMessageAggregateError { // Wire format mirrors leanSpec PR #717: `TypeOneMultiSignature` is a flat // `{ participants, proof }` pair. The signed `message` and `slot` are NOT // carried on the envelope — verifiers rederive each component's binding -// from the surrounding block body (attestation `data` + slot for body -// components, block root + slot for the proposer component). +// from the surrounding block body (attestation `data` + slot). // -// `MultiMessageAggregate` carries the raw lean-multisig Type-2 bytes. -// Component participant bitfields come from -// `block.body.attestations[i].aggregation_bits` (and `block.proposer_index` for -// the trailing proposer entry). +// `MultiMessageAggregate` carries the raw lean-multisig Type-2 bytes for the +// body attestations only; the proposer signature is carried separately in +// `BlockProof::proposer_signature`. Component participant bitfields come from +// `block.body.attestations[i].aggregation_bits`. /// Maximum number of distinct `AttestationData` entries permitted in a single /// block. Canonical home for the cap shared across `ethlambda-blockchain`, @@ -298,11 +373,13 @@ mod tests { }; let signed = SignedBlock { message: block, - proof: MultiMessageAggregate::default(), + proof: BlockProof::default(), }; let bytes = signed.to_ssz(); let decoded = SignedBlock::from_ssz_bytes(&bytes).expect("decode"); - assert_eq!(decoded.proof.proof.len(), 0); + // Default proof: empty attestation aggregate + the blank XMSS placeholder. + assert_eq!(decoded.proof.attestation_proof.proof.len(), 0); + assert_eq!(decoded.proof.proposer_signature, blank_xmss_signature()); assert_eq!(decoded.message.slot, signed.message.slot); assert_eq!( decoded.message.proposer_index, @@ -321,4 +398,42 @@ mod tests { assert_eq!(&encoded[4..], proof_bytes); assert_eq!(aggregate.proof_bytes(), proof_bytes); } + + #[test] + fn signed_block_ssz_round_trip_with_proposer_signature() { + let block = Block { + slot: 9, + proposer_index: 2, + parent_root: H256::ZERO, + state_root: H256::ZERO, + body: BlockBody::default(), + }; + // A distinctive, full-size XMSS signature blob (fixed `SIGNATURE_SIZE`). + let proposer_bytes: Vec = (0..crate::signature::SIGNATURE_SIZE) + .map(|i| (i % 251) as u8) + .collect(); + let proposer_signature = XmssSignature::try_from(proposer_bytes.clone()).unwrap(); + let attestation_bytes: Vec = (0..64).collect(); + let signed = SignedBlock { + message: block, + proof: BlockProof::new( + proposer_signature, + MultiMessageAggregate::from_bytes(&attestation_bytes).unwrap(), + ), + }; + + let bytes = signed.to_ssz(); + let decoded = SignedBlock::from_ssz_bytes(&bytes).expect("decode"); + + assert_eq!( + &*decoded.proof.proposer_signature, + proposer_bytes.as_slice() + ); + assert_eq!( + decoded.proof.attestation_proof.proof_bytes(), + attestation_bytes + ); + assert_eq!(decoded.message.slot, 9); + assert_eq!(decoded.message.proposer_index, 2); + } } diff --git a/crates/net/p2p/src/req_resp/handlers.rs b/crates/net/p2p/src/req_resp/handlers.rs index ad041766..789bdbbf 100644 --- a/crates/net/p2p/src/req_resp/handlers.rs +++ b/crates/net/p2p/src/req_resp/handlers.rs @@ -602,7 +602,7 @@ mod tests { use super::*; use ethlambda_storage::{ForkCheckpoints, backend::InMemoryBackend}; use ethlambda_types::{ - block::{Block, BlockBody, MultiMessageAggregate}, + block::{Block, BlockBody, BlockProof}, state::State, }; use std::sync::Arc; @@ -616,7 +616,7 @@ mod tests { state_root: H256::ZERO, body: BlockBody::default(), }, - proof: MultiMessageAggregate::default(), + proof: BlockProof::default(), } } diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index 09268765..60dad66e 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -421,7 +421,7 @@ mod tests { #[tokio::test] async fn test_get_latest_finalized_block() { use ethlambda_types::{ - block::{Block, BlockBody, MultiMessageAggregate, SignedBlock}, + block::{Block, BlockBody, BlockProof, SignedBlock}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, }; @@ -442,7 +442,7 @@ mod tests { let block_root = block.header().hash_tree_root(); let signed_block = SignedBlock { message: block, - proof: MultiMessageAggregate::default(), + proof: BlockProof::default(), }; // Persist the signed block and mark it as the latest finalized checkpoint. @@ -482,7 +482,7 @@ mod tests { #[tokio::test] async fn test_get_latest_finalized_block_serves_genesis_with_placeholder_proof() { - use ethlambda_types::block::{MultiMessageAggregate, SignedBlock}; + use ethlambda_types::block::{BlockProof, SignedBlock}; use libssz::SszEncode; // Genesis-anchored store: `init_store` writes the header + state but no @@ -501,7 +501,7 @@ mod tests { .expect("genesis served via get_signed_block"); let expected = SignedBlock { message: genesis_block.message.clone(), - proof: MultiMessageAggregate::default(), + proof: BlockProof::default(), }; let expected_ssz = expected.to_ssz(); diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 0700b02a..6d011005 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -6,9 +6,7 @@ use crate::error::Error; use ethlambda_types::{ attestation::{AggregationBits, AttestationData, HashedAttestationData, bits_is_subset}, - block::{ - Block, BlockBody, BlockHeader, MultiMessageAggregate, SignedBlock, TypeOneMultiSignature, - }, + block::{Block, BlockBody, BlockHeader, BlockProof, SignedBlock, TypeOneMultiSignature}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, signature::ValidatorSignature, @@ -1135,12 +1133,12 @@ impl Store { let sig_key = encode_slot_root_key(header.slot, root); let proof = match view.get(Table::BlockSignatures, &sig_key).expect("get") { Some(proof_bytes) => { - MultiMessageAggregate::from_ssz_bytes(&proof_bytes).expect("valid block proof") + BlockProof::from_ssz_bytes(&proof_bytes).expect("valid block proof") } // Synthesis only covers the genesis-style anchor (slot 0). Any other // missing-proof case is a storage corruption that should surface // as `None` rather than fabricating a block with an empty proof. - None if header.slot == 0 => MultiMessageAggregate::default(), + None if header.slot == 0 => BlockProof::default(), None => return None, }; @@ -2568,7 +2566,7 @@ mod tests { .expect("genesis block must be retrievable with synthetic proof"); assert_eq!(signed.message.slot, 0); - assert_eq!(signed.proof, MultiMessageAggregate::default()); + assert_eq!(signed.proof, BlockProof::default()); } /// The synthesis branch must be confined to the slot-0 anchor: a