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 1/2] 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 From adbc4598031b07df0dc96d32c68b8975e161d345 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, 29 Jul 2026 18:22:19 -0300 Subject: [PATCH 2/2] fix(blockchain): count proposer signature verify in crypto_elapsed The crypto timer started after the proposer signature check, so that verification landed between the two reported components and was counted in neither: an attestation-less block logged crypto_elapsed=0ns while total_elapsed showed ~370us of real work. That gap is exactly the cost this branch moves out of the aggregate, so leaving it unreported hides the effect of carrying the proposer signature outside the block proof. Start the crypto segment at the end of the structural one instead, so both verification steps are counted and the two components sum to total_elapsed. Slot narrowing moves above the boundary as well: it is a range check, so it belongs in the structural segment rather than in the gap. --- crates/blockchain/src/store.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 6b8414d4..c13d2271 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -1133,14 +1133,20 @@ pub fn verify_block_signatures( } let block_root = block.hash_tree_root(); - let structural_elapsed = total_start.elapsed(); - + // Slot narrowing is a range check, so it closes out the structural segment + // rather than landing between two timers and escaping both. let block_slot_u32 = u32::try_from(block.slot).map_err(|_| StoreError::SlotOutOfRange(block.slot))?; + // One instant ends the structural segment and starts the crypto one, so the + // two reported components sum to `total_elapsed` with no gap between them. + let structural_end = std::time::Instant::now(); + let structural_elapsed = structural_end.duration_since(total_start); // 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. + // Counted in `crypto_elapsed` below along with the aggregate, so this + // cost is reported rather than falling between the timers. let proposer_validator = validators .get(block.proposer_index as usize) .ok_or(StoreError::InvalidValidatorIndex)?; @@ -1155,7 +1161,6 @@ pub fn verify_block_signatures( // 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(); if attestations.is_empty() { if !signed_block .proof @@ -1197,9 +1202,10 @@ pub fn verify_block_signatures( ) .map_err(StoreError::AggregateVerificationFailed)?; } - let crypto_elapsed = crypto_start.elapsed(); + let total_end = std::time::Instant::now(); + let crypto_elapsed = total_end.duration_since(structural_end); + let total_elapsed = total_end.duration_since(total_start); - let total_elapsed = total_start.elapsed(); info!( slot = block.slot, attestation_count = attestations.len(),