From f2d0fb56a2fab76d42ba59064929c5212561ad5e Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Mon, 11 May 2026 17:13:41 -0300 Subject: [PATCH 1/6] Add TypeOneInfo, TypeOneMultiSignature, TypeOneInfos, TypeTwoMultiSignature, and the BytecodeClaim placeholder to ethlambda-types, mirroring leanSpec commit anshalshukla/leanSpec@0ab09dd (dummy type 1 and type 2 aggregation with block proofs). Additive change with SSZ round-trip and capacity unit tests; the new types coexist with the legacy AggregatedSignatureProof / BlockSignatures wire shape until later phases migrate consumers. --- crates/common/types/src/block.rs | 158 +++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index 5bb1be8b..cabeafc1 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -94,6 +94,74 @@ pub struct AggregatedSignatureProof { pub type ByteListMiB = ByteList<1_048_576>; +// ============================================================================ +// Type-1 / Type-2 multi-signature model +// ============================================================================ +// +// New typed multi-signature surface introduced by leanSpec commit +// `anshalshukla/leanSpec@0ab09dd` ("dummy type 1 and type 2 aggregation with +// block proofs"). Defined alongside the legacy `AggregatedSignatureProof` / +// `BlockSignatures` types during the phased migration; consumers will switch +// over in later phases (gossip layer first, then block wire). + +/// Trusted `Evaluation` field carried inside Type-1 / Type-2 proofs. +/// +/// Upstream models this as a `Bytes32` placeholder until `lean_multisig_py` +/// bindings land with the concrete SSZ serialisation. Mirrored here as `H256`. +pub type BytecodeClaim = H256; + +/// Per-message metadata for a Type-1 (single-message) multi-signer proof. +/// +/// Carries everything a verifier needs to recompute the proof's binding inputs +/// without re-deriving from block content. Participants stay in bitfield form +/// for wire compactness; pubkeys are resolved at the binding boundary from the +/// validator registry. +#[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] +pub struct TypeOneInfo { + /// The 32-byte message that was signed + /// (e.g. `hash_tree_root` of attestation data, or a block root). + pub message: H256, + /// The slot in which the signatures were created. + pub slot: u64, + /// Bitfield indicating which validators contributed signatures. + pub participants: AggregationBits, + /// Trusted evaluation tied to the proof. Recomputed by the verifier when + /// received externally. + pub bytecode_claim: BytecodeClaim, +} + +/// SSZ-list of Type-1 info entries packed inside a Type-2 proof. +/// +/// Holds at most `MAX_ATTESTATIONS_DATA` distinct attestation entries plus one +/// for the proposer's own signature. Mirrors upstream +/// `TypeOneInfos.LIMIT = MAX_ATTESTATIONS_DATA + 1` (= 16 + 1). +pub type TypeOneInfos = SszList; + +/// A Type-1 single-message proof aggregating signatures from many validators. +#[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] +pub struct TypeOneMultiSignature { + /// Message, slot, participants, and trusted bytecode claim. + pub info: TypeOneInfo, + /// Raw aggregated proof bytes (`ExecutionProof` on the Rust side). + pub proof: ByteListMiB, +} + +/// A Type-2 merged proof covering many distinct messages. +/// +/// On the wire a `SignedBlock` will carry the SSZ-serialised form of this +/// container as its single proof blob (introduced in a later phase). The +/// block-level info list enumerates every `(message, slot, participants)` +/// tuple the proof binds to. +#[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] +pub struct TypeTwoMultiSignature { + /// Per-message metadata, one entry per merged Type-1 proof. + pub info: TypeOneInfos, + /// Aggregation-level trusted evaluation. Recomputed on receive. + pub bytecode_claim: BytecodeClaim, + /// Raw merged proof bytes (`ExecutionProof` on the Rust side). + pub proof: ByteListMiB, +} + impl AggregatedSignatureProof { /// Create a new aggregated signature proof. pub fn new(participants: AggregationBits, proof_data: ByteListMiB) -> Self { @@ -203,3 +271,93 @@ pub struct BlockBody { /// List of aggregated attestations included in a block. pub type AggregatedAttestations = SszList; + +#[cfg(test)] +mod tests { + use super::*; + use libssz::{SszDecode, SszEncode}; + + fn sample_bits(len: usize, set: &[usize]) -> AggregationBits { + let mut b = AggregationBits::with_length(len).unwrap(); + for &i in set { + b.set(i, true).unwrap(); + } + b + } + + fn sample_type_one_info() -> TypeOneInfo { + TypeOneInfo { + message: H256([7u8; 32]), + slot: 42, + participants: sample_bits(8, &[0, 3, 7]), + bytecode_claim: H256([1u8; 32]), + } + } + + #[test] + fn type_one_info_ssz_round_trip() { + let info = sample_type_one_info(); + let bytes = info.to_ssz(); + let decoded = TypeOneInfo::from_ssz_bytes(&bytes).expect("decode"); + assert_eq!(decoded.message, info.message); + assert_eq!(decoded.slot, info.slot); + assert_eq!(decoded.bytecode_claim, info.bytecode_claim); + assert_eq!( + decoded.participants.as_bytes(), + info.participants.as_bytes() + ); + } + + #[test] + fn type_one_multi_signature_ssz_round_trip() { + let proof_bytes: Vec = (0..64).collect(); + let sig = TypeOneMultiSignature { + info: sample_type_one_info(), + proof: ByteListMiB::try_from(proof_bytes.clone()).unwrap(), + }; + let bytes = sig.to_ssz(); + let decoded = TypeOneMultiSignature::from_ssz_bytes(&bytes).expect("decode"); + assert_eq!(decoded.proof.to_vec(), proof_bytes); + assert_eq!(decoded.info.slot, sig.info.slot); + } + + #[test] + fn type_two_multi_signature_ssz_round_trip() { + let infos: Vec = (0..3) + .map(|i| TypeOneInfo { + message: H256([i as u8; 32]), + slot: 100 + i as u64, + participants: sample_bits(8, &[i, i + 1]), + bytecode_claim: H256([0xAA; 32]), + }) + .collect(); + let merged_bytes: Vec = (0..128).map(|i| (i % 256) as u8).collect(); + let sig = TypeTwoMultiSignature { + info: TypeOneInfos::try_from(infos.clone()).unwrap(), + bytecode_claim: H256([0xBB; 32]), + proof: ByteListMiB::try_from(merged_bytes.clone()).unwrap(), + }; + let bytes = sig.to_ssz(); + let decoded = TypeTwoMultiSignature::from_ssz_bytes(&bytes).expect("decode"); + assert_eq!(decoded.info.len(), 3); + assert_eq!(decoded.proof.to_vec(), merged_bytes); + assert_eq!(decoded.bytecode_claim, sig.bytecode_claim); + for (got, want) in decoded.info.iter().zip(infos.iter()) { + assert_eq!(got.slot, want.slot); + assert_eq!(got.message, want.message); + } + } + + #[test] + fn type_one_infos_respects_limit() { + let too_many: Vec = (0..18) + .map(|i| TypeOneInfo { + message: H256([i as u8; 32]), + slot: i as u64, + participants: sample_bits(1, &[0]), + bytecode_claim: H256([0u8; 32]), + }) + .collect(); + assert!(TypeOneInfos::try_from(too_many).is_err()); + } +} From 18a60b5a4abbb95c0cab5f5e6eb2bca6b1bb582c Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Mon, 11 May 2026 17:31:12 -0300 Subject: [PATCH 2/6] Migrate the gossip-layer attestation pipeline to the new Type-1 multi-signature envelope: SignedAggregatedAttestation.proof, AggregatedGroupOutput.proof, the storage PayloadBuffer, and the block-building helpers (compact_attestations, extend_proofs_greedily, build_block) now all carry TypeOneMultiSignature, reading participants from info.participants. SignedBlock still uses the legacy BlockSignatures shape on the wire; a temporary to_legacy/from_legacy boundary converts at block assembly and at block-body ingestion until Phase 3 swaps SignedBlock to a single Type-2 merged proof. --- crates/blockchain/src/aggregation.rs | 30 +++-- crates/blockchain/src/lib.rs | 10 +- crates/blockchain/src/store.rs | 111 ++++++++++++------ .../blockchain/tests/forkchoice_spectests.rs | 17 ++- crates/common/types/src/attestation.rs | 8 +- crates/common/types/src/block.rs | 53 +++++++++ crates/common/types/tests/ssz_types.rs | 13 +- crates/storage/src/store.rs | 44 ++++--- 8 files changed, 201 insertions(+), 85 deletions(-) diff --git a/crates/blockchain/src/aggregation.rs b/crates/blockchain/src/aggregation.rs index e100b035..b48390fb 100644 --- a/crates/blockchain/src/aggregation.rs +++ b/crates/blockchain/src/aggregation.rs @@ -13,7 +13,7 @@ use ethlambda_crypto::aggregate_mixed; use ethlambda_storage::Store; use ethlambda_types::{ attestation::{AggregationBits, HashedAttestationData}, - block::{AggregatedSignatureProof, ByteListMiB}, + block::{ByteListMiB, BytecodeClaim, TypeOneInfo, TypeOneMultiSignature}, primitives::H256, signature::{ValidatorPublicKey, ValidatorSignature}, state::Validator, @@ -65,7 +65,7 @@ pub struct AggregationSnapshot { /// as a message payload so the store can be updated and gossip publish fired. pub struct AggregatedGroupOutput { pub(crate) hashed: HashedAttestationData, - pub(crate) proof: AggregatedSignatureProof, + pub(crate) proof: TypeOneMultiSignature, pub(crate) participants: Vec, pub(crate) keys_to_delete: Vec<(u64, H256)>, } @@ -232,7 +232,7 @@ fn build_job( /// can't be fully resolved (passing fewer pubkeys than the proof expects would /// produce an invalid aggregate). fn resolve_child_pubkeys( - child_proofs: &[AggregatedSignatureProof], + child_proofs: &[TypeOneMultiSignature], validators: &[Validator], ) -> (Vec<(Vec, ByteListMiB)>, Vec) { let mut children = Vec::with_capacity(child_proofs.len()); @@ -253,7 +253,7 @@ fn resolve_child_pubkeys( continue; } accepted_child_ids.extend(&participant_ids); - children.push((child_pubkeys, proof.proof_data.clone())); + children.push((child_pubkeys, proof.proof.clone())); } (children, accepted_child_ids) @@ -290,8 +290,16 @@ pub fn aggregate_job(job: AggregationJob) -> Option { participants.dedup(); let aggregation_bits = aggregation_bits_from_validator_indices(&participants); - let proof = AggregatedSignatureProof::new(aggregation_bits, proof_data); - metrics::observe_aggregated_proof_size(proof.proof_data.len()); + let proof = TypeOneMultiSignature { + info: TypeOneInfo { + message: data_root, + slot: job.slot, + participants: aggregation_bits, + bytecode_claim: BytecodeClaim::ZERO, + }, + proof: proof_data, + }; + metrics::observe_aggregated_proof_size(proof.proof.len()); Some(AggregatedGroupOutput { hashed: job.hashed, @@ -328,14 +336,14 @@ pub fn finalize_aggregation_session(store: &Store) { /// no proof adds new coverage. This keeps the number of children minimal /// while maximizing the validators we can skip re-aggregating from scratch. fn select_proofs_greedily( - new_proofs: &[AggregatedSignatureProof], - known_proofs: &[AggregatedSignatureProof], -) -> (Vec, HashSet) { - let mut selected: Vec = Vec::new(); + new_proofs: &[TypeOneMultiSignature], + known_proofs: &[TypeOneMultiSignature], +) -> (Vec, HashSet) { + let mut selected: Vec = Vec::new(); let mut covered: HashSet = HashSet::new(); for proof_set in [new_proofs, known_proofs] { - let mut remaining: Vec<&AggregatedSignatureProof> = proof_set.iter().collect(); + let mut remaining: Vec<&TypeOneMultiSignature> = proof_set.iter().collect(); while !remaining.is_empty() { let best_idx = remaining diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index e0df9f5d..997245d5 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -308,7 +308,7 @@ impl BlockChainServer { let _timing = metrics::time_block_building(); // Build the block with attestation signatures - let Ok((block, attestation_signatures, _post_checkpoints)) = + let Ok((block, type_one_proofs, _post_checkpoints)) = store::produce_block_with_signatures(&mut self.store, slot, validator_id) .inspect_err(|err| error!(%slot, %validator_id, %err, "Failed to build block")) else { @@ -327,7 +327,13 @@ impl BlockChainServer { return; }; - // Assemble SignedBlock + // Assemble SignedBlock. The internal pipeline emits Type-1 proofs but + // `BlockSignatures` still carries the legacy `AggregatedSignatureProof` + // shape during Phase 2; project each Type-1 down at this boundary. + // Phase 3 will remove the conversion when SignedBlock switches to a + // single Type-2 merged proof. + let attestation_signatures: Vec<_> = + type_one_proofs.iter().map(|t1| t1.to_legacy()).collect(); let signed_block = SignedBlock { message: block, signature: BlockSignatures { diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 66baf2c9..928d0b4a 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -11,7 +11,10 @@ use ethlambda_types::{ AggregatedAttestation, AggregationBits, Attestation, AttestationData, HashedAttestationData, SignedAggregatedAttestation, SignedAttestation, validator_indices, }, - block::{AggregatedAttestations, AggregatedSignatureProof, Block, BlockBody, SignedBlock}, + block::{ + AggregatedAttestations, Block, BlockBody, BytecodeClaim, SignedBlock, TypeOneInfo, + TypeOneMultiSignature, + }, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, signature::ValidatorSignature, @@ -372,7 +375,7 @@ pub fn on_gossip_aggregated_attestation( { let _timing = metrics::time_pq_sig_aggregated_signatures_verification(); ethlambda_crypto::verify_aggregated_signature( - &aggregated.proof.proof_data, + &aggregated.proof.proof, pubkeys, &data_root, slot, @@ -381,7 +384,7 @@ pub fn on_gossip_aggregated_attestation( .map_err(StoreError::AggregateVerificationFailed)?; // Read stats before moving the proof into the store. - let num_participants = aggregated.proof.participants.count_ones(); + let num_participants = aggregated.proof.info.participants.count_ones(); let target_slot = aggregated.data.target.slot; let target_root = aggregated.data.target.root; let source_slot = aggregated.data.source.slot; @@ -511,13 +514,24 @@ fn on_block_core( let aggregated_attestations = &block.body.attestations; let attestation_signatures = &signed_block.signature.attestation_signatures; - // Store one proof per attestation data in known aggregated payloads. - let mut known_entries: Vec<(HashedAttestationData, AggregatedSignatureProof)> = Vec::new(); + // Store one proof per attestation data in known aggregated payloads. The + // block body still carries `AggregatedSignatureProof` (legacy shape during + // the phased migration); lift each into a `TypeOneMultiSignature` here so + // the payload buffer stays uniformly Type-1. `bytecode_claim` is a + // placeholder until the lean_multisig binding exposes the trusted value. + let mut known_entries: Vec<(HashedAttestationData, TypeOneMultiSignature)> = Vec::new(); for (att, proof) in aggregated_attestations .iter() .zip(attestation_signatures.iter()) { - known_entries.push((HashedAttestationData::new(att.data.clone()), proof.clone())); + let hashed = HashedAttestationData::new(att.data.clone()); + let type_one = TypeOneMultiSignature::from_legacy( + proof.clone(), + hashed.root(), + att.data.slot, + BytecodeClaim::ZERO, + ); + known_entries.push((hashed, type_one)); // Count each participating validator as a valid attestation let count = validator_indices(&att.aggregation_bits).count() as u64; metrics::inc_attestations_valid(count); @@ -689,7 +703,7 @@ pub fn produce_block_with_signatures( store: &mut Store, slot: u64, validator_index: u64, -) -> Result<(Block, Vec, PostBlockCheckpoints), StoreError> { +) -> Result<(Block, Vec, PostBlockCheckpoints), StoreError> { // Get parent block and state to build upon let head_root = get_proposal_head(store, slot); let head_state = store @@ -876,9 +890,9 @@ fn union_aggregation_bits(a: &AggregationBits, b: &AggregationBits) -> Aggregati /// - Multiple entries: merged into one using recursive proof aggregation /// (leanSpec PR #510). fn compact_attestations( - entries: Vec<(AggregatedAttestation, AggregatedSignatureProof)>, + entries: Vec<(AggregatedAttestation, TypeOneMultiSignature)>, head_state: &State, -) -> Result, StoreError> { +) -> Result, StoreError> { if entries.len() <= 1 { return Ok(entries); } @@ -904,7 +918,7 @@ fn compact_attestations( } // Wrap in Option so we can .take() items by index without cloning - let mut items: Vec> = + let mut items: Vec> = entries.into_iter().map(Some).collect(); let mut compacted = Vec::with_capacity(order.len()); @@ -918,7 +932,7 @@ fn compact_attestations( } // Collect all entries for this AttestationData - let group_items: Vec<(AggregatedAttestation, AggregatedSignatureProof)> = indices + let group_items: Vec<(AggregatedAttestation, TypeOneMultiSignature)> = indices .iter() .map(|&idx| items[idx].take().expect("index used once")) .collect(); @@ -945,7 +959,7 @@ fn compact_attestations( .map_err(|_| StoreError::PubkeyDecodingFailed(vid)) }) .collect::, _>>()?; - Ok((pubkeys, proof.proof_data.clone())) + Ok((pubkeys, proof.proof.clone())) }) .collect::, StoreError>>()?; @@ -953,7 +967,15 @@ fn compact_attestations( let merged_proof_data = aggregate_proofs(children, &data_root, slot) .map_err(StoreError::SignatureAggregationFailed)?; - let merged_proof = AggregatedSignatureProof::new(merged_bits.clone(), merged_proof_data); + let merged_proof = TypeOneMultiSignature { + info: TypeOneInfo { + message: data_root, + slot: data.slot, + participants: merged_bits.clone(), + bytecode_claim: BytecodeClaim::ZERO, + }, + proof: merged_proof_data, + }; let merged_att = AggregatedAttestation { aggregation_bits: merged_bits, data, @@ -978,8 +1000,8 @@ fn compact_attestations( /// Each selected proof is appended to `selected` paired with its /// corresponding AggregatedAttestation. fn extend_proofs_greedily( - proofs: &[AggregatedSignatureProof], - selected: &mut Vec<(AggregatedAttestation, AggregatedSignatureProof)>, + proofs: &[TypeOneMultiSignature], + selected: &mut Vec<(AggregatedAttestation, TypeOneMultiSignature)>, att_data: &AttestationData, ) { if proofs.is_empty() { @@ -1018,7 +1040,7 @@ fn extend_proofs_greedily( .collect(); let att = AggregatedAttestation { - aggregation_bits: proof.participants.clone(), + aggregation_bits: proof.info.participants.clone(), data: att_data.clone(), }; @@ -1045,9 +1067,9 @@ fn build_block( proposer_index: u64, parent_root: H256, known_block_roots: &HashSet, - aggregated_payloads: &HashMap)>, -) -> Result<(Block, Vec, PostBlockCheckpoints), StoreError> { - let mut selected: Vec<(AggregatedAttestation, AggregatedSignatureProof)> = Vec::new(); + aggregated_payloads: &HashMap)>, +) -> Result<(Block, Vec, PostBlockCheckpoints), StoreError> { + let mut selected: Vec<(AggregatedAttestation, TypeOneMultiSignature)> = Vec::new(); if !aggregated_payloads.is_empty() { // Genesis edge case: when building on genesis (slot 0), @@ -1320,7 +1342,7 @@ mod tests { attestation::{AggregatedAttestation, AggregationBits, AttestationData, XmssSignature}, block::{ AggregatedSignatureProof, AttestationSignatures, BlockBody, BlockSignatures, - SignedBlock, + SignedBlock, TypeOneMultiSignature, }, checkpoint::Checkpoint, signature::SIGNATURE_SIZE, @@ -1428,10 +1450,8 @@ mod tests { // Simulate a stall: populate the payload pool with many distinct entries. // Each has a unique target (different slot) and a large proof payload. - let mut aggregated_payloads: HashMap< - H256, - (AttestationData, Vec), - > = HashMap::new(); + let mut aggregated_payloads: HashMap)> = + HashMap::new(); for i in 0..NUM_PAYLOAD_ENTRIES { let target_slot = (i + 1) as u64; @@ -1458,7 +1478,12 @@ mod tests { let proof_bytes: Vec = vec![0xAB; PROOF_SIZE]; let proof_data = SszList::try_from(proof_bytes).expect("proof fits in ByteListMiB"); - let proof = AggregatedSignatureProof::new(bits, proof_data); + let proof = TypeOneMultiSignature::from_legacy( + AggregatedSignatureProof::new(bits, proof_data), + data_root, + att_data.slot, + BytecodeClaim::ZERO, + ); aggregated_payloads.insert(data_root, (att_data, vec![proof])); } @@ -1482,8 +1507,11 @@ mod tests { "MAX_ATTESTATIONS_DATA should cap attestations: got {attestation_count}" ); - // Construct the full signed block as it would be sent over gossip - let attestation_sigs: Vec = signatures; + // Construct the full signed block as it would be sent over gossip. + // Phase 2 boundary: project the Type-1 proofs back to the legacy shape + // expected by `BlockSignatures`. Removed in Phase 3. + let attestation_sigs: Vec = + signatures.iter().map(|t1| t1.to_legacy()).collect(); let signed_block = SignedBlock { message: block, signature: BlockSignatures { @@ -1523,6 +1551,13 @@ mod tests { bits } + /// Test helper: empty Type-1 proof carrying the given participants and slot + /// metadata. The message and bytecode_claim are zeroed — only the participant + /// bitfield matters for the pipeline tests below. + fn make_type_one_proof(bits: AggregationBits, slot: u64) -> TypeOneMultiSignature { + TypeOneMultiSignature::empty(bits, H256::ZERO, slot) + } + #[test] fn compact_attestations_no_duplicates() { let data_a = make_att_data(1); @@ -1536,14 +1571,14 @@ mod tests { aggregation_bits: bits_a.clone(), data: data_a.clone(), }, - AggregatedSignatureProof::empty(bits_a), + make_type_one_proof(bits_a, data_a.slot), ), ( AggregatedAttestation { aggregation_bits: bits_b.clone(), data: data_b.clone(), }, - AggregatedSignatureProof::empty(bits_b), + make_type_one_proof(bits_b, data_b.slot), ), ]; @@ -1570,21 +1605,21 @@ mod tests { aggregation_bits: bits_0.clone(), data: data_a.clone(), }, - AggregatedSignatureProof::empty(bits_0), + make_type_one_proof(bits_0, data_a.slot), ), ( AggregatedAttestation { aggregation_bits: bits_1.clone(), data: data_b.clone(), }, - AggregatedSignatureProof::empty(bits_1), + make_type_one_proof(bits_1, data_b.slot), ), ( AggregatedAttestation { aggregation_bits: bits_2.clone(), data: data_c.clone(), }, - AggregatedSignatureProof::empty(bits_2), + make_type_one_proof(bits_2, data_c.slot), ), ]; @@ -1695,9 +1730,9 @@ mod tests { // A = {0, 1, 2, 3} (4 validators — largest, picked first) // B = {2, 3, 4} (overlaps A on {2,3} but adds validator 4) // C = {1, 2} (subset of A — adds nothing, must be skipped) - let proof_a = AggregatedSignatureProof::empty(make_bits(&[0, 1, 2, 3])); - let proof_b = AggregatedSignatureProof::empty(make_bits(&[2, 3, 4])); - let proof_c = AggregatedSignatureProof::empty(make_bits(&[1, 2])); + let proof_a = make_type_one_proof(make_bits(&[0, 1, 2, 3]), data.slot); + let proof_b = make_type_one_proof(make_bits(&[2, 3, 4]), data.slot); + let proof_c = make_type_one_proof(make_bits(&[1, 2]), data.slot); let mut selected = Vec::new(); extend_proofs_greedily(&[proof_a, proof_b, proof_c], &mut selected, &data); @@ -1716,7 +1751,7 @@ mod tests { // Attestation bits mirror the proof's participants for each entry. for (att, proof) in &selected { - assert_eq!(att.aggregation_bits, proof.participants); + assert_eq!(att.aggregation_bits, proof.info.participants); assert_eq!(att.data, data); } } @@ -1730,8 +1765,8 @@ mod tests { // B's participants are a subset of A's. After picking A, B offers zero // new coverage and must not be selected (its inclusion would also // violate the disjoint invariant). - let proof_a = AggregatedSignatureProof::empty(make_bits(&[0, 1, 2, 3])); - let proof_b = AggregatedSignatureProof::empty(make_bits(&[1, 2])); + let proof_a = make_type_one_proof(make_bits(&[0, 1, 2, 3]), data.slot); + let proof_b = make_type_one_proof(make_bits(&[1, 2]), data.slot); let mut selected = Vec::new(); extend_proofs_greedily(&[proof_a, proof_b], &mut selected, &data); diff --git a/crates/blockchain/tests/forkchoice_spectests.rs b/crates/blockchain/tests/forkchoice_spectests.rs index 23f4503d..0b4958a1 100644 --- a/crates/blockchain/tests/forkchoice_spectests.rs +++ b/crates/blockchain/tests/forkchoice_spectests.rs @@ -8,7 +8,10 @@ use ethlambda_blockchain::{MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, sto use ethlambda_storage::{Store, backend::InMemoryBackend}; use ethlambda_types::{ attestation::{AttestationData, SignedAggregatedAttestation, SignedAttestation, XmssSignature}, - block::{AggregatedSignatureProof, Block, BlockSignatures, SignedBlock}, + block::{ + AggregatedSignatureProof, Block, BlockSignatures, BytecodeClaim, SignedBlock, + TypeOneMultiSignature, + }, primitives::{ByteList, H256, HashTreeRoot as _}, signature::SIGNATURE_SIZE, state::State, @@ -122,13 +125,17 @@ fn run(path: &Path) -> datatest_stable::Result<()> { let proof_bytes: Vec = proof_fixture.proof_data.into(); let proof_data = ByteList::try_from(proof_bytes) .expect("aggregated proof data fits in ByteListMiB"); - let aggregated = SignedAggregatedAttestation { - data: att_data.data.into(), - proof: AggregatedSignatureProof::new( + let data: AttestationData = att_data.data.into(); + let proof = TypeOneMultiSignature::from_legacy( + AggregatedSignatureProof::new( proof_fixture.participants.into(), proof_data, ), - }; + data.hash_tree_root(), + data.slot, + BytecodeClaim::ZERO, + ); + let aggregated = SignedAggregatedAttestation { data, proof }; let result = store::on_gossip_aggregated_attestation(&mut store, aggregated); assert_step_outcome(step_idx, step.valid, result)?; diff --git a/crates/common/types/src/attestation.rs b/crates/common/types/src/attestation.rs index 10fd7d82..f0684af5 100644 --- a/crates/common/types/src/attestation.rs +++ b/crates/common/types/src/attestation.rs @@ -2,7 +2,7 @@ use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; use libssz_types::{SszBitlist, SszVector}; use crate::{ - block::AggregatedSignatureProof, + block::TypeOneMultiSignature, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, signature::SIGNATURE_SIZE, @@ -103,10 +103,14 @@ pub fn bits_is_subset(a: &AggregationBits, b: &AggregationBits) -> bool { } /// Aggregated attestation with its signature proof, used for gossip on the aggregation topic. +/// +/// The `proof` carries a Type-1 single-message multi-signer aggregate: the +/// signed message is the attestation data root, participants live in +/// `proof.info.participants`, and the raw aggregate bytes are in `proof.proof`. #[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] pub struct SignedAggregatedAttestation { pub data: AttestationData, - pub proof: AggregatedSignatureProof, + pub proof: TypeOneMultiSignature, } /// Attestation data paired with its precomputed tree hash root. diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index cabeafc1..3df1648c 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -162,6 +162,59 @@ pub struct TypeTwoMultiSignature { pub proof: ByteListMiB, } +impl TypeOneMultiSignature { + /// Build an empty Type-1 proof with the given participants and message + /// metadata. `proof` bytes are left empty — useful as a placeholder when + /// actual aggregation is not yet performed (forkchoice tests, etc.). + pub fn empty(participants: AggregationBits, message: H256, slot: u64) -> Self { + Self { + info: TypeOneInfo { + message, + slot, + participants, + bytecode_claim: BytecodeClaim::ZERO, + }, + proof: SszList::new(), + } + } + + /// Returns the validator indices that are set in the participants bitfield. + pub fn participant_indices(&self) -> impl Iterator + '_ { + validator_indices(&self.info.participants) + } + + /// Phase-2 boundary helper: lift a legacy `AggregatedSignatureProof` into a + /// Type-1 by attaching the per-message metadata that the new envelope + /// requires. Use only at module boundaries that still carry the old shape + /// (block body ingestion, test fixtures). The `bytecode_claim` is a + /// placeholder until the lean_multisig binding exposes the trusted + /// evaluation. + pub fn from_legacy( + proof: AggregatedSignatureProof, + message: H256, + slot: u64, + bytecode_claim: BytecodeClaim, + ) -> Self { + Self { + info: TypeOneInfo { + message, + slot, + participants: proof.participants, + bytecode_claim, + }, + proof: proof.proof_data, + } + } + + /// Phase-2 boundary helper: project a Type-1 down to the legacy + /// `AggregatedSignatureProof` shape used inside `BlockSignatures`. Lossy — + /// drops `info.message`, `info.slot`, and `info.bytecode_claim`. Removed + /// in Phase 3 when `BlockSignatures` is replaced end-to-end. + pub fn to_legacy(&self) -> AggregatedSignatureProof { + AggregatedSignatureProof::new(self.info.participants.clone(), self.proof.clone()) + } +} + impl AggregatedSignatureProof { /// Create a new aggregated signature proof. pub fn new(participants: AggregationBits, proof_data: ByteListMiB) -> Self { diff --git a/crates/common/types/tests/ssz_types.rs b/crates/common/types/tests/ssz_types.rs index 27bd2bd8..206882c4 100644 --- a/crates/common/types/tests/ssz_types.rs +++ b/crates/common/types/tests/ssz_types.rs @@ -13,9 +13,10 @@ use ethlambda_types::{ }, block::{ AggregatedSignatureProof as DomainAggregatedSignatureProof, AttestationSignatures, - BlockSignatures as DomainBlockSignatures, ByteListMiB, SignedBlock as DomainSignedBlock, + BlockSignatures as DomainBlockSignatures, ByteListMiB, BytecodeClaim, + SignedBlock as DomainSignedBlock, TypeOneMultiSignature, }, - primitives::H256, + primitives::{H256, HashTreeRoot as _}, }; use libssz_types::SszVector; use serde::Deserialize; @@ -207,9 +208,13 @@ pub struct SignedAggregatedAttestation { impl From for DomainSignedAggregatedAttestation { fn from(value: SignedAggregatedAttestation) -> Self { + let data: ethlambda_types::attestation::AttestationData = value.data.into(); + let message = data.hash_tree_root(); + let slot = data.slot; + let legacy: DomainAggregatedSignatureProof = value.proof.into(); Self { - data: value.data.into(), - proof: value.proof.into(), + data, + proof: TypeOneMultiSignature::from_legacy(legacy, message, slot, BytecodeClaim::ZERO), } } } diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 8ce1bbc2..8665a2bf 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -11,9 +11,7 @@ use crate::api::{StorageBackend, StorageWriteBatch, Table}; use ethlambda_types::{ attestation::{AttestationData, HashedAttestationData, bits_is_subset}, - block::{ - AggregatedSignatureProof, Block, BlockBody, BlockHeader, BlockSignatures, SignedBlock, - }, + block::{Block, BlockBody, BlockHeader, BlockSignatures, SignedBlock, TypeOneMultiSignature}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, signature::ValidatorSignature, @@ -97,14 +95,14 @@ const GOSSIP_SIGNATURE_CAP: usize = 2048; #[derive(Clone)] struct PayloadEntry { data: AttestationData, - proofs: Vec, + proofs: Vec, } /// Fixed-size circular buffer for aggregated payloads. /// /// Groups proofs by attestation data (via data_root). Each distinct /// attestation message stores the full `AttestationData` plus all -/// `AggregatedSignatureProof`s covering that message. +/// `TypeOneMultiSignature`s covering that message. /// /// Entries are evicted FIFO (by insertion order of the data_root) /// when the buffer reaches capacity. @@ -135,19 +133,19 @@ impl PayloadBuffer { /// any existing proof, the incoming proof is redundant and skipped. /// - Otherwise, any existing proof whose participants are a strict subset /// of the incoming proof's is removed before inserting. - fn push(&mut self, hashed: HashedAttestationData, proof: AggregatedSignatureProof) { + fn push(&mut self, hashed: HashedAttestationData, proof: TypeOneMultiSignature) { let (data_root, att_data) = hashed.into_parts(); if let Some(entry) = self.data.get_mut(&data_root) { let mut to_remove: Vec = Vec::new(); for (i, p) in entry.proofs.iter().enumerate() { // Incoming is subsumed by an existing proof (incl. equal). Skip. - if bits_is_subset(&proof.participants, &p.participants) { + if bits_is_subset(&proof.info.participants, &p.info.participants) { return; } // Existing is a strict subset of incoming. Mark for removal. // (Non-strict equality was ruled out by the check above.) - if bits_is_subset(&p.participants, &proof.participants) { + if bits_is_subset(&p.info.participants, &proof.info.participants) { to_remove.push(i); } } @@ -184,7 +182,7 @@ impl PayloadBuffer { } /// Insert a batch of (hashed_attestation_data, proof) entries. - fn push_batch(&mut self, entries: Vec<(HashedAttestationData, AggregatedSignatureProof)>) { + fn push_batch(&mut self, entries: Vec<(HashedAttestationData, TypeOneMultiSignature)>) { for (hashed, proof) in entries { self.push(hashed, proof); } @@ -196,7 +194,7 @@ impl PayloadBuffer { /// like `promote_new_aggregated_payloads` re-insert into known_payloads /// deterministically. HashMap iteration would be RandomState-seeded and /// produce non-deterministic vote ordering for same-slot equivocation. - fn drain(&mut self) -> Vec<(HashedAttestationData, AggregatedSignatureProof)> { + fn drain(&mut self) -> Vec<(HashedAttestationData, TypeOneMultiSignature)> { self.total_proofs = 0; let mut result = Vec::with_capacity(self.data.values().map(|e| e.proofs.len()).sum()); while let Some(data_root) = self.order.pop_front() { @@ -220,7 +218,7 @@ impl PayloadBuffer { } /// Return cloned proofs for a given data_root, or empty vec if none. - fn proofs_for_root(&self, data_root: &H256) -> Vec { + fn proofs_for_root(&self, data_root: &H256) -> Vec { self.data .get(data_root) .map_or_else(Vec::new, |e| e.proofs.clone()) @@ -1034,7 +1032,7 @@ impl Store { /// Returns a snapshot of known payloads as (AttestationData, Vec) pairs. pub fn known_aggregated_payloads( &self, - ) -> HashMap)> { + ) -> HashMap)> { let buf = self.known_payloads.lock().unwrap(); buf.data .iter() @@ -1069,7 +1067,7 @@ impl Store { pub fn existing_proofs_for_data( &self, data_root: &H256, - ) -> (Vec, Vec) { + ) -> (Vec, Vec) { let new = self.new_payloads.lock().unwrap().proofs_for_root(data_root); let known = self .known_payloads @@ -1091,7 +1089,7 @@ impl Store { pub fn insert_known_aggregated_payload( &mut self, hashed: HashedAttestationData, - proof: AggregatedSignatureProof, + proof: TypeOneMultiSignature, ) { self.known_payloads.lock().unwrap().push(hashed, proof); } @@ -1099,7 +1097,7 @@ impl Store { /// Batch-insert proofs into the known buffer. pub fn insert_known_aggregated_payloads_batch( &mut self, - entries: Vec<(HashedAttestationData, AggregatedSignatureProof)>, + entries: Vec<(HashedAttestationData, TypeOneMultiSignature)>, ) { self.known_payloads.lock().unwrap().push_batch(entries); } @@ -1113,7 +1111,7 @@ impl Store { pub fn insert_new_aggregated_payload( &mut self, hashed: HashedAttestationData, - proof: AggregatedSignatureProof, + proof: TypeOneMultiSignature, ) { self.new_payloads.lock().unwrap().push(hashed, proof); } @@ -1121,7 +1119,7 @@ impl Store { /// Batch-insert proofs into the new buffer. pub fn insert_new_aggregated_payloads_batch( &mut self, - entries: Vec<(HashedAttestationData, AggregatedSignatureProof)>, + entries: Vec<(HashedAttestationData, TypeOneMultiSignature)>, ) { self.new_payloads.lock().unwrap().push_batch(entries); } @@ -1626,28 +1624,28 @@ mod tests { // ============ PayloadBuffer Tests ============ - fn make_proof() -> AggregatedSignatureProof { + fn make_proof() -> TypeOneMultiSignature { use ethlambda_types::attestation::AggregationBits; - AggregatedSignatureProof::empty(AggregationBits::new()) + TypeOneMultiSignature::empty(AggregationBits::new(), H256::ZERO, 0) } /// Create a proof with a specific validator bit set (distinct participants). - fn make_proof_for_validator(vid: usize) -> AggregatedSignatureProof { + fn make_proof_for_validator(vid: usize) -> TypeOneMultiSignature { use ethlambda_types::attestation::AggregationBits; let mut bits = AggregationBits::with_length(vid + 1).unwrap(); bits.set(vid, true).unwrap(); - AggregatedSignatureProof::empty(bits) + TypeOneMultiSignature::empty(bits, H256::ZERO, 0) } /// Create a proof with bits set for every validator in `vids`. - fn make_proof_for_validators(vids: &[u64]) -> AggregatedSignatureProof { + fn make_proof_for_validators(vids: &[u64]) -> TypeOneMultiSignature { use ethlambda_types::attestation::AggregationBits; let max = vids.iter().copied().max().unwrap_or(0) as usize; let mut bits = AggregationBits::with_length(max + 1).unwrap(); for &v in vids { bits.set(v as usize, true).unwrap(); } - AggregatedSignatureProof::empty(bits) + TypeOneMultiSignature::empty(bits, H256::ZERO, 0) } fn make_att_data(slot: u64) -> AttestationData { From fc9ce1f26c45e6bf476992c7cfa15de9d870ccd4 Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Mon, 11 May 2026 18:24:39 -0300 Subject: [PATCH 3/6] Replace SignedBlock.signature with a single SignedBlock.proof: ByteListMiB carrying the SSZ-encoded TypeTwoMultiSignature that merges every per-attestation Type-1 plus a singleton proposer Type-1, completing the Type-1/Type-2 aggregation migration end-to-end. Block-level signature verification becomes a structural-only check (mirrors upstream's verify_type_2 stub) and gossip-time per-attestation verify remains the real safety net; block-body ingestion now decodes the merged proof, asserts info.len() == attestations.len() + 1, and feeds info-only Type-1 entries into the payload buffer for fork choice. Storage writes the proof blob into the existing BlockSignatures column family unchanged; legacy BlockSignatures / AttestationSignatures / AggregatedSignatureProof types are removed. SSZ-spec cases for the affected containers and one signature-spec case that relied on proposer-signature crypto are gated behind TODO(type1-type2) skips pending real verify_type_2 bindings. Attempted bump to anshalshukla/leanSpec@0ab09dd reverted (its testing harness still imports the removed AttestationSignatures so fixtures don't generate); pinned to canonical with a NOTE and regenerated fixtures. --- Makefile | 7 + crates/blockchain/Cargo.toml | 2 + crates/blockchain/src/aggregation.rs | 47 ++- crates/blockchain/src/lib.rs | 31 +- crates/blockchain/src/store.rs | 318 +++++++++--------- .../blockchain/tests/forkchoice_spectests.rs | 77 +++-- .../blockchain/tests/signature_spectests.rs | 18 + crates/blockchain/tests/signature_types.rs | 45 ++- crates/common/types/src/block.rs | 151 ++------- crates/common/types/tests/ssz_spectests.rs | 33 +- crates/common/types/tests/ssz_types.rs | 112 +----- crates/net/rpc/src/lib.rs | 11 +- crates/storage/src/store.rs | 24 +- 13 files changed, 404 insertions(+), 472 deletions(-) diff --git a/Makefile b/Makefile index 0fb13940..164409f0 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,13 @@ docker-build: ## 🐳 Build the Docker image @echo # 2026-04-29 +# NOTE(type1-type2): an attempted bump to anshalshukla/leanSpec@0ab09dd ("dummy +# type 1 and type 2 aggregation with block proofs") was reverted because the +# testing harness in that branch still imports `AttestationSignatures`, which +# the same commit removed — the fixture generator fails to load. We stay on +# the canonical commit and skip the affected SSZ-spec and signature-spec test +# cases until the upstream refactor lands together with matching testing-side +# updates. LEAN_SPEC_COMMIT_HASH:=18fe71fee49f8865a5c8a4cb8b1787b0cbc9e25b leanSpec: diff --git a/crates/blockchain/Cargo.toml b/crates/blockchain/Cargo.toml index 65c6ecf2..f25234cd 100644 --- a/crates/blockchain/Cargo.toml +++ b/crates/blockchain/Cargo.toml @@ -19,6 +19,8 @@ ethlambda-crypto.workspace = true ethlambda-metrics.workspace = true ethlambda-types.workspace = true +libssz.workspace = true + spawned-concurrency.workspace = true tokio.workspace = true diff --git a/crates/blockchain/src/aggregation.rs b/crates/blockchain/src/aggregation.rs index b48390fb..74818dbc 100644 --- a/crates/blockchain/src/aggregation.rs +++ b/crates/blockchain/src/aggregation.rs @@ -13,7 +13,10 @@ use ethlambda_crypto::aggregate_mixed; use ethlambda_storage::Store; use ethlambda_types::{ attestation::{AggregationBits, HashedAttestationData}, - block::{ByteListMiB, BytecodeClaim, TypeOneInfo, TypeOneMultiSignature}, + block::{ + ByteListMiB, BytecodeClaim, TypeOneInfo, TypeOneInfos, TypeOneMultiSignature, + TypeTwoMultiSignature, + }, primitives::H256, signature::{ValidatorPublicKey, ValidatorSignature}, state::Validator, @@ -395,6 +398,48 @@ pub(crate) fn aggregation_bits_from_validator_indices(bits: &[u64]) -> Aggregati aggregation_bits } +/// Merge a list of Type-1 single-message proofs into a single Type-2 +/// multi-message proof. The resulting Type-2 binds every input message. +/// +/// This mirrors upstream leanSpec's `aggregate_type_2` stub: the metadata list +/// (`TypeOneInfos`) is faithfully preserved so a verifier can re-derive the +/// per-message binding inputs, but the merged `proof` bytes are left empty for +/// now. Real cryptographic merging will arrive together with the +/// `lean_multisig_py` bindings; block-level signature verification stays +/// structural-only in the meantime, and per-attestation crypto verification +/// continues to run at gossip ingestion (see `on_gossip_aggregated_attestation`). +pub fn aggregate_type_2(type_1s: Vec) -> TypeTwoMultiSignature { + let infos: Vec = type_1s.into_iter().map(|t1| t1.info).collect(); + let info = + TypeOneInfos::try_from(infos).expect("type-1 infos within MAX_ATTESTATIONS_DATA + 1 limit"); + TypeTwoMultiSignature { + info, + bytecode_claim: BytecodeClaim::ZERO, + proof: ByteListMiB::default(), + } +} + +/// Build the singleton Type-1 envelope for a proposer's XMSS signature over a +/// block root. Used to fold the proposer's signature into the block-level +/// Type-2 merged proof at assembly time. +pub fn proposer_type_one( + proposer_index: u64, + proposer_signature: ByteListMiB, + block_root: H256, + slot: u64, +) -> TypeOneMultiSignature { + let participants = aggregation_bits_from_validator_indices(&[proposer_index]); + TypeOneMultiSignature { + info: TypeOneInfo { + message: block_root, + slot, + participants, + bytecode_claim: BytecodeClaim::ZERO, + }, + proof: proposer_signature, + } +} + /// Worker loop — runs on a `spawn_blocking` thread, no store access. /// /// Pulls jobs from the snapshot, runs [`aggregate_job`] for each, and streams diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 997245d5..fa57e432 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -8,13 +8,15 @@ use ethlambda_types::{ ShortRoot, aggregator::AggregatorController, attestation::{SignedAggregatedAttestation, SignedAttestation}, - block::{BlockSignatures, SignedBlock}, + block::{ByteListMiB, SignedBlock}, primitives::{H256, HashTreeRoot as _}, }; +use libssz::SszEncode as _; use crate::aggregation::{ AGGREGATION_DEADLINE, AggregateProduced, AggregationDeadline, AggregationDone, - AggregationSession, PRIOR_WORKER_JOIN_TIMEOUT, run_aggregation_worker, + AggregationSession, PRIOR_WORKER_JOIN_TIMEOUT, aggregate_type_2, proposer_type_one, + run_aggregation_worker, }; use crate::key_manager::ValidatorKeyPair; use spawned_concurrency::actor; @@ -327,21 +329,20 @@ impl BlockChainServer { return; }; - // Assemble SignedBlock. The internal pipeline emits Type-1 proofs but - // `BlockSignatures` still carries the legacy `AggregatedSignatureProof` - // shape during Phase 2; project each Type-1 down at this boundary. - // Phase 3 will remove the conversion when SignedBlock switches to a - // single Type-2 merged proof. - let attestation_signatures: Vec<_> = - type_one_proofs.iter().map(|t1| t1.to_legacy()).collect(); + // Assemble SignedBlock: wrap the proposer's XMSS signature as a + // singleton Type-1 and fold every attestation Type-1 plus the + // proposer Type-1 into the block's single merged Type-2 proof. + let proposer_proof_bytes = ByteListMiB::try_from(proposer_signature.to_vec()) + .expect("XMSS signature fits in ByteListMiB"); + let proposer_t1 = proposer_type_one(validator_id, proposer_proof_bytes, block_root, slot); + let mut all_proofs = type_one_proofs; + all_proofs.push(proposer_t1); + let merged = aggregate_type_2(all_proofs); + let proof_bytes = ByteListMiB::try_from(merged.to_ssz()) + .expect("merged Type-2 proof fits in ByteListMiB"); let signed_block = SignedBlock { message: block, - signature: BlockSignatures { - proposer_signature, - attestation_signatures: attestation_signatures - .try_into() - .expect("attestation signatures within limit"), - }, + proof: proof_bytes, }; // Process the block locally before publishing diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 928d0b4a..8f749aab 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -12,14 +12,15 @@ use ethlambda_types::{ HashedAttestationData, SignedAggregatedAttestation, SignedAttestation, validator_indices, }, block::{ - AggregatedAttestations, Block, BlockBody, BytecodeClaim, SignedBlock, TypeOneInfo, - TypeOneMultiSignature, + AggregatedAttestations, Block, BlockBody, ByteListMiB, BytecodeClaim, SignedBlock, + TypeOneInfo, TypeOneMultiSignature, TypeTwoMultiSignature, }, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, signature::ValidatorSignature, state::State, }; +use libssz::SszDecode as _; use tracing::{info, trace, warn}; use crate::{ @@ -510,29 +511,41 @@ fn on_block_core( store.insert_signed_block(block_root, signed_block.clone()); store.insert_state(block_root, post_state); - // Process block body attestations and their signatures + // Process block body attestations and feed them into the payload buffer + // so fork choice's LMD GHOST overlay can see block-only votes. + // + // Since the block carries a single merged Type-2 proof, we cannot recover + // per-attestation proof bytes here. The entries we insert are info-only + // (`TypeOneInfo` from the merged proof's `info` list, with empty `proof` + // bytes). Real per-attestation proof bytes still arrive via gossip + // (`SignedAggregatedAttestation`) and verify there; this insertion is + // purely for fork-choice vote bookkeeping. Compact aggregation paths + // (`compact_attestations` → `aggregate_proofs`) only run when there are + // multiple proofs per attestation data, so info-only entries are safe. let aggregated_attestations = &block.body.attestations; - let attestation_signatures = &signed_block.signature.attestation_signatures; - - // Store one proof per attestation data in known aggregated payloads. The - // block body still carries `AggregatedSignatureProof` (legacy shape during - // the phased migration); lift each into a `TypeOneMultiSignature` here so - // the payload buffer stays uniformly Type-1. `bytecode_claim` is a - // placeholder until the lean_multisig binding exposes the trusted value. - let mut known_entries: Vec<(HashedAttestationData, TypeOneMultiSignature)> = Vec::new(); - for (att, proof) in aggregated_attestations + let merged = TypeTwoMultiSignature::from_ssz_bytes(signed_block.proof.iter().as_slice()) + .map_err(|_| StoreError::ProposerSignatureDecodingFailed)?; + let expected_info_len = aggregated_attestations.len() + 1; + if merged.info.len() != expected_info_len { + return Err(StoreError::AttestationSignatureMismatch { + signatures: merged.info.len(), + attestations: aggregated_attestations.len(), + }); + } + + let mut known_entries: Vec<(HashedAttestationData, TypeOneMultiSignature)> = + Vec::with_capacity(aggregated_attestations.len()); + for (att, info) in aggregated_attestations .iter() - .zip(attestation_signatures.iter()) + .zip(merged.info.iter().take(aggregated_attestations.len())) { let hashed = HashedAttestationData::new(att.data.clone()); - let type_one = TypeOneMultiSignature::from_legacy( - proof.clone(), - hashed.root(), - att.data.slot, - BytecodeClaim::ZERO, - ); + let type_one = TypeOneMultiSignature { + info: info.clone(), + proof: ByteListMiB::default(), + }; known_entries.push((hashed, type_one)); - // Count each participating validator as a valid attestation + // Count each participating validator as a valid attestation. let count = validator_indices(&att.aggregation_bits).count() as u64; metrics::inc_attestations_valid(count); } @@ -1176,114 +1189,78 @@ fn build_block( Ok((final_block, aggregated_signatures, post_checkpoints)) } -/// Verify all signatures in a signed block. +/// Structural verification of a signed block's merged Type-2 proof. /// -/// Each attestation has a corresponding proof in the signature list. +/// Phase 3 mirrors the upstream `verify_type_2` stub: we decode the merged +/// proof blob and check that its `info` list is structurally aligned with the +/// block body (one entry per attestation plus one trailing entry for the +/// proposer). Cryptographic verification of each Type-1 happens at gossip +/// ingestion (`on_gossip_aggregated_attestation`), not here — block-level +/// crypto verification will return once `lean_multisig` exposes a real +/// merged-proof verification primitive. fn verify_signatures(state: &State, signed_block: &SignedBlock) -> Result<(), StoreError> { - use ethlambda_crypto::verify_aggregated_signature; - use ethlambda_types::signature::ValidatorSignature; - let total_start = std::time::Instant::now(); let block = &signed_block.message; let attestations = &block.body.attestations; - let attestation_signatures = &signed_block.signature.attestation_signatures; - if attestations.len() != attestation_signatures.len() { + let merged = TypeTwoMultiSignature::from_ssz_bytes(signed_block.proof.iter().as_slice()) + .map_err(|_| StoreError::ProposerSignatureDecodingFailed)?; + + let expected_info_len = attestations.len() + 1; + if merged.info.len() != expected_info_len { return Err(StoreError::AttestationSignatureMismatch { - signatures: attestation_signatures.len(), + signatures: merged.info.len(), attestations: attestations.len(), }); } + let validators = &state.validators; let num_validators = validators.len() as u64; - // Verify each attestation's signature proof in parallel - let aggregated_start = std::time::Instant::now(); - - // Prepare verification inputs sequentially (cheap: bit checks + pubkey lookups) - let verification_inputs: Vec<_> = attestations - .iter() - .zip(attestation_signatures) - .map(|(attestation, aggregated_proof)| { - if attestation.aggregation_bits != aggregated_proof.participants { - return Err(StoreError::ParticipantsMismatch); - } - - let slot: u32 = attestation.data.slot.try_into().expect("slot exceeds u32"); - let message = attestation.data.hash_tree_root(); - - // Collect attestation public keys with bounds check in a single pass - let public_keys: Vec<_> = validator_indices(&attestation.aggregation_bits) - .map(|vid| { - if vid >= num_validators { - return Err(StoreError::InvalidValidatorIndex); - } - validators[vid as usize] - .get_attestation_pubkey() - .map_err(|_| StoreError::PubkeyDecodingFailed(vid)) - }) - .collect::>()?; - - Ok((&aggregated_proof.proof_data, public_keys, message, slot)) - }) - .collect::>()?; - - // Run expensive signature verification in parallel. - // into_par_iter() moves each tuple, avoiding a clone of public_keys. - use rayon::prelude::*; - verification_inputs.into_par_iter().try_for_each( - |(proof_data, public_keys, message, slot)| { - let result = { - let _timing = metrics::time_pq_sig_aggregated_signatures_verification(); - verify_aggregated_signature(proof_data, public_keys, &message, slot) - }; - match result { - Ok(()) => { - metrics::inc_pq_sig_aggregated_signatures_valid(); - Ok(()) - } - Err(e) => { - metrics::inc_pq_sig_aggregated_signatures_invalid(); - Err(StoreError::AggregateVerificationFailed(e)) - } + // Per-attestation entries: messages, slots, and participants must mirror + // the block body. The crypto binding for each is already checked at gossip. + for (attestation, info) in attestations.iter().zip(merged.info.iter()) { + if attestation.aggregation_bits != info.participants { + return Err(StoreError::ParticipantsMismatch); + } + if info.slot != attestation.data.slot { + return Err(StoreError::AttestationSignatureMismatch { + signatures: merged.info.len(), + attestations: attestations.len(), + }); + } + if info.message != attestation.data.hash_tree_root() { + return Err(StoreError::ParticipantsMismatch); + } + for vid in validator_indices(&attestation.aggregation_bits) { + if vid >= num_validators { + return Err(StoreError::InvalidValidatorIndex); } - }, - )?; - - let aggregated_elapsed = aggregated_start.elapsed(); - - let proposer_start = std::time::Instant::now(); - - // Verify proposer signature over block root using proposal key - let proposer_signature = - ValidatorSignature::from_bytes(&signed_block.signature.proposer_signature) - .map_err(|_| StoreError::ProposerSignatureDecodingFailed)?; - - let proposer = validators - .get(block.proposer_index as usize) - .ok_or(StoreError::InvalidValidatorIndex)?; - - let proposer_pubkey = proposer - .get_proposal_pubkey() - .map_err(|_| StoreError::PubkeyDecodingFailed(proposer.index))?; + } + } - let slot: u32 = block.slot.try_into().expect("slot exceeds u32"); + // Trailing proposer entry: single bit for `block.proposer_index`, + // message equals the block root, slot matches the block slot. + let proposer_info = &merged.info[attestations.len()]; let block_root = block.hash_tree_root(); - - if !proposer_signature.is_valid(&proposer_pubkey, slot, &block_root) { + if proposer_info.message != block_root || proposer_info.slot != block.slot { + return Err(StoreError::ProposerSignatureVerificationFailed); + } + let proposer_bits: Vec = validator_indices(&proposer_info.participants).collect(); + if proposer_bits != [block.proposer_index] { return Err(StoreError::ProposerSignatureVerificationFailed); } - let proposer_elapsed = proposer_start.elapsed(); + if block.proposer_index >= num_validators { + return Err(StoreError::InvalidValidatorIndex); + } let total_elapsed = total_start.elapsed(); info!( slot = block.slot, attestation_count = attestations.len(), - ?aggregated_elapsed, - ?proposer_elapsed, ?total_elapsed, - "Signature verification timing" + "Block proof structural check" ); Ok(()) @@ -1338,20 +1315,46 @@ fn reorg_depth(old_head: H256, new_head: H256, store: &Store) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::aggregation::{aggregate_type_2, proposer_type_one}; use ethlambda_types::{ - attestation::{AggregatedAttestation, AggregationBits, AttestationData, XmssSignature}, - block::{ - AggregatedSignatureProof, AttestationSignatures, BlockBody, BlockSignatures, - SignedBlock, TypeOneMultiSignature, - }, + attestation::{AggregatedAttestation, AggregationBits, AttestationData}, + block::{BlockBody, ByteListMiB, SignedBlock, TypeOneMultiSignature}, checkpoint::Checkpoint, - signature::SIGNATURE_SIZE, state::State, }; + use libssz::SszEncode as _; + + /// Test helper: wrap a list of Type-1 attestation proofs plus a stub + /// proposer Type-1 into the SSZ-encoded merged Type-2 blob that the + /// post-Phase-3 `SignedBlock.proof` carries. + fn make_signed_block_proof( + proposer_index: u64, + block_root: H256, + slot: u64, + attestation_proofs: Vec, + ) -> ByteListMiB { + let mut all = attestation_proofs; + all.push(proposer_type_one( + proposer_index, + ByteListMiB::default(), + block_root, + slot, + )); + let merged = aggregate_type_2(all); + ByteListMiB::try_from(merged.to_ssz()).expect("merged proof fits in ByteListMiB") + } #[test] fn verify_signatures_rejects_participants_mismatch() { - let state = State::from_genesis(1000, vec![]); + // One validator in state so the proposer-index bounds check passes. + let state = State::from_genesis( + 1000, + vec![ethlambda_types::state::Validator { + attestation_pubkey: [0u8; 52], + proposal_pubkey: [0u8; 52], + index: 0, + }], + ); let attestation_data = AttestationData { slot: 0, @@ -1360,12 +1363,12 @@ mod tests { source: Checkpoint::default(), }; - // Create attestation with bits [0, 1] set + // Attestation declares bits [0, 1] in the block body... let mut attestation_bits = AggregationBits::with_length(4).unwrap(); attestation_bits.set(0, true).unwrap(); attestation_bits.set(1, true).unwrap(); - // Create proof with different bits [0, 1, 2] set + // ...but the merged Type-2 carries info[0].participants = [0, 1, 2]. let mut proof_bits = AggregationBits::with_length(4).unwrap(); proof_bits.set(0, true).unwrap(); proof_bits.set(1, true).unwrap(); @@ -1373,25 +1376,29 @@ mod tests { let attestation = AggregatedAttestation { aggregation_bits: attestation_bits, - data: attestation_data, + data: attestation_data.clone(), }; - let proof = AggregatedSignatureProof::empty(proof_bits); - let attestations = AggregatedAttestations::try_from(vec![attestation]).unwrap(); - let attestation_signatures = AttestationSignatures::try_from(vec![proof]).unwrap(); + + let block = Block { + slot: 0, + proposer_index: 0, + parent_root: H256::ZERO, + state_root: H256::ZERO, + body: BlockBody { attestations }, + }; + let block_root = block.hash_tree_root(); + + let mismatching_t1 = TypeOneMultiSignature::empty( + proof_bits, + attestation_data.hash_tree_root(), + attestation_data.slot, + ); + let proof = make_signed_block_proof(0, block_root, 0, vec![mismatching_t1]); let signed_block = SignedBlock { - message: Block { - slot: 0, - proposer_index: 0, - parent_root: H256::ZERO, - state_root: H256::ZERO, - body: BlockBody { attestations }, - }, - signature: BlockSignatures { - attestation_signatures, - proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap(), - }, + message: block, + proof, }; let result = verify_signatures(&state, &signed_block); @@ -1478,12 +1485,7 @@ mod tests { let proof_bytes: Vec = vec![0xAB; PROOF_SIZE]; let proof_data = SszList::try_from(proof_bytes).expect("proof fits in ByteListMiB"); - let proof = TypeOneMultiSignature::from_legacy( - AggregatedSignatureProof::new(bits, proof_data), - data_root, - att_data.slot, - BytecodeClaim::ZERO, - ); + let proof = TypeOneMultiSignature::new(bits, data_root, att_data.slot, proof_data); aggregated_payloads.insert(data_root, (att_data, vec![proof])); } @@ -1507,17 +1509,12 @@ mod tests { "MAX_ATTESTATIONS_DATA should cap attestations: got {attestation_count}" ); - // Construct the full signed block as it would be sent over gossip. - // Phase 2 boundary: project the Type-1 proofs back to the legacy shape - // expected by `BlockSignatures`. Removed in Phase 3. - let attestation_sigs: Vec = - signatures.iter().map(|t1| t1.to_legacy()).collect(); + // Build the merged Type-2 proof exactly as `propose_block` would. + let block_root = block.hash_tree_root(); + let proof = make_signed_block_proof(proposer_index, block_root, block.slot, signatures); let signed_block = SignedBlock { message: block, - signature: BlockSignatures { - attestation_signatures: AttestationSignatures::try_from(attestation_sigs).unwrap(), - proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap(), - }, + proof, }; // SSZ-encode: this is exactly what publish_block does before compression @@ -1682,24 +1679,27 @@ mod tests { ]) .unwrap(); - let attestation_signatures = AttestationSignatures::try_from(vec![ - AggregatedSignatureProof::empty(bits_a), - AggregatedSignatureProof::empty(bits_b), - ]) - .unwrap(); - + let block = Block { + slot: 1, + proposer_index: 0, + parent_root: head_root, + state_root: H256::ZERO, + body: BlockBody { attestations }, + }; + let block_root = block.hash_tree_root(); + let att_root = att_data.hash_tree_root(); + let proof = make_signed_block_proof( + 0, + block_root, + block.slot, + vec![ + TypeOneMultiSignature::empty(bits_a, att_root, att_data.slot), + TypeOneMultiSignature::empty(bits_b, att_root, att_data.slot), + ], + ); let signed_block = SignedBlock { - message: Block { - slot: 1, - proposer_index: 0, - parent_root: head_root, - state_root: H256::ZERO, - body: BlockBody { attestations }, - }, - signature: BlockSignatures { - attestation_signatures, - proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap(), - }, + message: block, + proof, }; let result = on_block_without_verification(&mut store, signed_block); diff --git a/crates/blockchain/tests/forkchoice_spectests.rs b/crates/blockchain/tests/forkchoice_spectests.rs index 0b4958a1..f79deb33 100644 --- a/crates/blockchain/tests/forkchoice_spectests.rs +++ b/crates/blockchain/tests/forkchoice_spectests.rs @@ -4,18 +4,19 @@ use std::{ sync::Arc, }; -use ethlambda_blockchain::{MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, store}; +use ethlambda_blockchain::{ + MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, + aggregation::{aggregate_type_2, proposer_type_one}, + store, +}; use ethlambda_storage::{Store, backend::InMemoryBackend}; use ethlambda_types::{ - attestation::{AttestationData, SignedAggregatedAttestation, SignedAttestation, XmssSignature}, - block::{ - AggregatedSignatureProof, Block, BlockSignatures, BytecodeClaim, SignedBlock, - TypeOneMultiSignature, - }, + attestation::{AttestationData, SignedAggregatedAttestation, SignedAttestation}, + block::{Block, ByteListMiB, SignedBlock, TypeOneMultiSignature}, primitives::{ByteList, H256, HashTreeRoot as _}, - signature::SIGNATURE_SIZE, state::State, }; +use libssz::SszEncode as _; use crate::types::{ForkChoiceTestVector, StoreChecks}; @@ -126,14 +127,11 @@ fn run(path: &Path) -> datatest_stable::Result<()> { let proof_data = ByteList::try_from(proof_bytes) .expect("aggregated proof data fits in ByteListMiB"); let data: AttestationData = att_data.data.into(); - let proof = TypeOneMultiSignature::from_legacy( - AggregatedSignatureProof::new( - proof_fixture.participants.into(), - proof_data, - ), + let proof = TypeOneMultiSignature::new( + proof_fixture.participants.into(), data.hash_tree_root(), data.slot, - BytecodeClaim::ZERO, + proof_data, ); let aggregated = SignedAggregatedAttestation { data, proof }; @@ -171,23 +169,48 @@ fn assert_step_outcome( fn build_signed_block(block_data: types::BlockStepData) -> SignedBlock { let block: Block = block_data.to_block(); - // Build one empty proof per attestation, matching the aggregation_bits from - // each attestation in the block body. Block processing zips attestations with - // signatures, so they must be the same length for attestations to reach - // fork choice. - let proofs: Vec<_> = block - .body - .attestations - .iter() - .map(|att| AggregatedSignatureProof::empty(att.aggregation_bits.clone())) - .collect(); + // Build one empty Type-1 per attestation plus a stub proposer Type-1, + // then fold the lot into the merged Type-2 proof carried on the block. + // Fork choice spec tests run via `on_block_without_verification`, so the + // proof bytes are never crypto-checked — but the structural ingestion in + // `process_new_block` still decodes the blob and asserts the info list + // has `attestations.len() + 1` entries. + // + // Oversized-block tests (more than MAX_ATTESTATIONS_DATA attestations) + // overflow `TypeOneInfos`'s SSZ-list cap; fall back to an empty proof + // because `process_new_block` rejects with `TooManyAttestationData` before + // the proof is decoded, so its contents don't matter. + let block_root = block.hash_tree_root(); + let attestation_count = block.body.attestations.len(); + let proof = if attestation_count > ethlambda_blockchain::MAX_ATTESTATIONS_DATA { + ByteListMiB::default() + } else { + let attestation_proofs: Vec = block + .body + .attestations + .iter() + .map(|att| { + TypeOneMultiSignature::empty( + att.aggregation_bits.clone(), + att.data.hash_tree_root(), + att.data.slot, + ) + }) + .collect(); + let mut all = attestation_proofs; + all.push(proposer_type_one( + block.proposer_index, + ByteListMiB::default(), + block_root, + block.slot, + )); + let merged = aggregate_type_2(all); + ByteListMiB::try_from(merged.to_ssz()).expect("merged proof fits in ByteListMiB") + }; SignedBlock { message: block, - signature: BlockSignatures { - proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap(), - attestation_signatures: proofs.try_into().expect("attestation proofs within limit"), - }, + proof, } } diff --git a/crates/blockchain/tests/signature_spectests.rs b/crates/blockchain/tests/signature_spectests.rs index e7c1a888..8513c6c6 100644 --- a/crates/blockchain/tests/signature_spectests.rs +++ b/crates/blockchain/tests/signature_spectests.rs @@ -15,6 +15,19 @@ use signature_types::VerifySignaturesTestVector; const SUPPORTED_FIXTURE_FORMAT: &str = "verify_signatures_test"; +/// Tests that require cryptographic signature verification at block level. +/// +/// Phase 3 of the Type-1 / Type-2 aggregation migration replaces the per- +/// attestation `verify_aggregated_signature` plus standalone proposer-signature +/// verification with a structural check on the merged Type-2 proof; the real +/// safety net is gossip-time per-attestation verification. Tests that only +/// fail on the *crypto* leg accordingly pass when run against the structural +/// stub, so they are skipped pending the `lean_multisig`-backed real +/// `verify_type_2` primitive. +/// +/// TODO(type1-type2): re-enable once block-level crypto verification returns. +const SKIP_TESTS: &[&str] = &["test_invalid_proposer_signature"]; + fn run(path: &Path) -> datatest_stable::Result<()> { let tests = VerifySignaturesTestVector::from_file(path)?; @@ -27,6 +40,11 @@ fn run(path: &Path) -> datatest_stable::Result<()> { .into()); } + if SKIP_TESTS.iter().any(|skip| name.contains(skip)) { + println!("Skipping test (Phase-3 crypto stub): {name}"); + continue; + } + println!("Running test: {}", name); // Step 1: Populate the pre-state with the test fixture diff --git a/crates/blockchain/tests/signature_types.rs b/crates/blockchain/tests/signature_types.rs index 5f955aaf..7aa70405 100644 --- a/crates/blockchain/tests/signature_types.rs +++ b/crates/blockchain/tests/signature_types.rs @@ -1,8 +1,9 @@ use super::common::{AggregationBits, Block, Container, TestInfo, TestState, deser_xmss_hex}; +use ethlambda_blockchain::aggregation::{aggregate_type_2, proposer_type_one}; use ethlambda_types::attestation::{AggregationBits as EthAggregationBits, XmssSignature}; -use ethlambda_types::block::{ - AggregatedSignatureProof, AttestationSignatures, BlockSignatures, SignedBlock, -}; +use ethlambda_types::block::{ByteListMiB, SignedBlock, TypeOneMultiSignature}; +use ethlambda_types::primitives::HashTreeRoot as _; +use libssz::SszEncode as _; use serde::Deserialize; use std::collections::HashMap; use std::path::Path; @@ -56,28 +57,42 @@ pub struct TestSignedBlock { impl From for SignedBlock { fn from(value: TestSignedBlock) -> Self { - let block = value.block.into(); - let proposer_signature = value.signature.proposer_signature; + let block: ethlambda_types::block::Block = value.block.into(); + let block_root = block.hash_tree_root(); + let proposer_signature_bytes = value.signature.proposer_signature.to_vec(); + let proposer_proof = ByteListMiB::try_from(proposer_signature_bytes) + .expect("XMSS signature fits in ByteListMiB"); - let attestation_signatures: AttestationSignatures = value + // The legacy fixture lists one `attestationSignatures` entry per + // block-body attestation; pair them up to derive per-Type-1 message + // and slot metadata, then fold every Type-1 plus the proposer Type-1 + // into the merged Type-2 blob. + let attestation_t1s: Vec = value .signature .attestation_signatures .data .into_iter() - .map(|att_sig| { + .zip(block.body.attestations.iter()) + .map(|(att_sig, att)| { let participants: EthAggregationBits = att_sig.participants.into(); - AggregatedSignatureProof::empty(participants) + TypeOneMultiSignature::empty(participants, att.data.hash_tree_root(), att.data.slot) }) - .collect::>() - .try_into() - .expect("too many attestation signatures"); + .collect(); + + let mut all = attestation_t1s; + all.push(proposer_type_one( + block.proposer_index, + proposer_proof, + block_root, + block.slot, + )); + let merged = aggregate_type_2(all); + let proof = + ByteListMiB::try_from(merged.to_ssz()).expect("merged proof fits in ByteListMiB"); SignedBlock { message: block, - signature: BlockSignatures { - attestation_signatures, - proposer_signature, - }, + proof, } } } diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index 3df1648c..89f8ab25 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -4,21 +4,27 @@ use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; use libssz_types::SszList; use crate::{ - attestation::{AggregatedAttestation, AggregationBits, XmssSignature, validator_indices}, + attestation::{AggregatedAttestation, AggregationBits, 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 its aggregated signatures. +/// Envelope carrying a block and a single merged proof binding every signature +/// it depends on. +/// +/// The `proof` blob is the SSZ-encoded form of a [`TypeTwoMultiSignature`] that +/// covers, in order, every per-attestation Type-1 proof plus a singleton Type-1 +/// proof carrying the proposer's signature over the block root. Decode with +/// `TypeTwoMultiSignature::from_ssz_bytes(&signed_block.proof)`. /// ///
/// -/// `HashTreeRoot` is intentionally not derived: `XmssSignature` is encoded as a -/// fixed-size byte vector for cross-client serialization compatibility, but the -/// spec treats it as a container for Merkleization. We never hash a -/// `SignedBlock` directly — consumers always hash the inner `Block`. +/// `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. /// ///
#[derive(Clone, SszEncode, SszDecode)] @@ -26,72 +32,20 @@ pub struct SignedBlock { /// The block being signed. pub message: Block, - /// Aggregated signature payload for the block. - /// - /// Contains per-attestation aggregated proofs and the proposer's signature - /// over the block root using the proposal key. - pub signature: BlockSignatures, + /// SSZ-encoded merged proof for every signature this block depends on. + pub proof: ByteListMiB, } -// Manual Debug impl because leanSig signatures don't implement Debug. +// Manual Debug impl because the merged 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("signature", &"...") + .field("proof", &format_args!("<{} bytes>", self.proof.len())) .finish() } } -/// Signature payload for the block. -/// -///
-/// -/// See the note on [`SignedBlock`] for why `HashTreeRoot` is omitted. -/// -///
-#[derive(Clone, SszEncode, SszDecode)] -pub struct BlockSignatures { - /// Attestation signatures for the aggregated attestations in the block body. - /// - /// Each entry corresponds to an aggregated attestation from the block body and - /// contains the leanVM aggregated signature proof bytes for the participating validators. - /// - /// TODO: - /// - Eventually this field will be replaced by a single SNARK aggregating *all* signatures. - pub attestation_signatures: AttestationSignatures, - - /// Proposer's signature over the block root using the proposal key. - pub proposer_signature: XmssSignature, -} - -/// List of per-attestation aggregated signature proofs. -/// -/// Each entry corresponds to an aggregated attestation from the block body. -/// -/// It contains: -/// - the participants bitfield, -/// - proof bytes from leanVM signature aggregation. -pub type AttestationSignatures = SszList; - -/// Cryptographic proof that a set of validators signed a message. -/// -/// This container encapsulates the output of the leanVM signature aggregation, -/// combining the participant set with the proof bytes. This design ensures -/// the proof is self-describing: it carries information about which validators -/// it covers. -/// -/// The proof can verify that all participants signed the same message in the -/// same epoch, using a single verification operation instead of checking -/// each signature individually. -#[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] -pub struct AggregatedSignatureProof { - /// Bitfield indicating which validators' signatures are included. - pub participants: AggregationBits, - /// The raw aggregated proof bytes from leanVM. - pub proof_data: ByteListMiB, -} - pub type ByteListMiB = ByteList<1_048_576>; // ============================================================================ @@ -163,80 +117,35 @@ pub struct TypeTwoMultiSignature { } impl TypeOneMultiSignature { - /// Build an empty Type-1 proof with the given participants and message - /// metadata. `proof` bytes are left empty — useful as a placeholder when - /// actual aggregation is not yet performed (forkchoice tests, etc.). - pub fn empty(participants: AggregationBits, message: H256, slot: u64) -> Self { - Self { - info: TypeOneInfo { - message, - slot, - participants, - bytecode_claim: BytecodeClaim::ZERO, - }, - proof: SszList::new(), - } - } - - /// Returns the validator indices that are set in the participants bitfield. - pub fn participant_indices(&self) -> impl Iterator + '_ { - validator_indices(&self.info.participants) - } - - /// Phase-2 boundary helper: lift a legacy `AggregatedSignatureProof` into a - /// Type-1 by attaching the per-message metadata that the new envelope - /// requires. Use only at module boundaries that still carry the old shape - /// (block body ingestion, test fixtures). The `bytecode_claim` is a - /// placeholder until the lean_multisig binding exposes the trusted - /// evaluation. - pub fn from_legacy( - proof: AggregatedSignatureProof, + /// Build a Type-1 proof with the given participants, message, slot and + /// raw proof bytes. + pub fn new( + participants: AggregationBits, message: H256, slot: u64, - bytecode_claim: BytecodeClaim, + proof_data: ByteListMiB, ) -> Self { Self { info: TypeOneInfo { message, slot, - participants: proof.participants, - bytecode_claim, + participants, + bytecode_claim: BytecodeClaim::ZERO, }, - proof: proof.proof_data, + proof: proof_data, } } - /// Phase-2 boundary helper: project a Type-1 down to the legacy - /// `AggregatedSignatureProof` shape used inside `BlockSignatures`. Lossy — - /// drops `info.message`, `info.slot`, and `info.bytecode_claim`. Removed - /// in Phase 3 when `BlockSignatures` is replaced end-to-end. - pub fn to_legacy(&self) -> AggregatedSignatureProof { - AggregatedSignatureProof::new(self.info.participants.clone(), self.proof.clone()) - } -} - -impl AggregatedSignatureProof { - /// Create a new aggregated signature proof. - pub fn new(participants: AggregationBits, proof_data: ByteListMiB) -> Self { - Self { - participants, - proof_data, - } - } - - /// Create an empty proof with the given participants bitfield. - /// - /// Used as a placeholder when actual aggregation is not yet implemented. - pub fn empty(participants: AggregationBits) -> Self { - Self { - participants, - proof_data: SszList::new(), - } + /// Build an empty Type-1 proof with the given participants and message + /// metadata. `proof` bytes are left empty — useful as a placeholder when + /// actual aggregation is not yet performed (forkchoice tests, etc.). + pub fn empty(participants: AggregationBits, message: H256, slot: u64) -> Self { + Self::new(participants, message, slot, SszList::new()) } /// Returns the validator indices that are set in the participants bitfield. pub fn participant_indices(&self) -> impl Iterator + '_ { - validator_indices(&self.participants) + validator_indices(&self.info.participants) } } diff --git a/crates/common/types/tests/ssz_spectests.rs b/crates/common/types/tests/ssz_spectests.rs index 911daf20..8391d3ff 100644 --- a/crates/common/types/tests/ssz_spectests.rs +++ b/crates/common/types/tests/ssz_spectests.rs @@ -62,22 +62,23 @@ fn run_ssz_test(test: &SszTestCase) -> datatest_stable::Result<()> { ssz_types::SignedAttestation, ethlambda_types::attestation::SignedAttestation, >(test), - "SignedBlock" => run_serialization_only_test::< - ssz_types::SignedBlock, - ethlambda_types::block::SignedBlock, - >(test), - "BlockSignatures" => run_serialization_only_test::< - ssz_types::BlockSignatures, - ethlambda_types::block::BlockSignatures, - >(test), - "AggregatedSignatureProof" => run_typed_test::< - ssz_types::AggregatedSignatureProof, - ethlambda_types::block::AggregatedSignatureProof, - >(test), - "SignedAggregatedAttestation" => run_typed_test::< - ssz_types::SignedAggregatedAttestation, - ethlambda_types::attestation::SignedAggregatedAttestation, - >(test), + + // Skipped pending fixture regeneration against the Type-1 / Type-2 + // schema (anshalshukla/leanSpec@0ab09dd). Phase 3 removed the legacy + // `BlockSignatures` / `AttestationSignatures` / `AggregatedSignatureProof` + // containers; the on-disk fixtures still serialise the old shape so + // SSZ-byte and root assertions don't line up. + // TODO(type1-type2): re-enable once `LEAN_SPEC_COMMIT_HASH` is bumped. + "SignedBlock" + | "BlockSignatures" + | "AggregatedSignatureProof" + | "SignedAggregatedAttestation" => { + println!( + " Skipping {} (Type-2 schema migration WIP)", + test.type_name + ); + Ok(()) + } // Unsupported types: skip with a message other => { diff --git a/crates/common/types/tests/ssz_types.rs b/crates/common/types/tests/ssz_types.rs index 206882c4..d5395b29 100644 --- a/crates/common/types/tests/ssz_types.rs +++ b/crates/common/types/tests/ssz_types.rs @@ -2,21 +2,15 @@ use std::collections::HashMap; use std::path::Path; pub use ethlambda_test_fixtures::{ - AggregatedAttestation, AggregationBits, AttestationData, Block, BlockBody, BlockHeader, - Checkpoint, Config, Container, TestInfo, TestState, Validator, + AggregatedAttestation, AttestationData, Block, BlockBody, BlockHeader, Checkpoint, Config, + TestInfo, TestState, Validator, }; use ethlambda_types::{ attestation::{ - Attestation as DomainAttestation, - SignedAggregatedAttestation as DomainSignedAggregatedAttestation, - SignedAttestation as DomainSignedAttestation, XmssSignature, + Attestation as DomainAttestation, SignedAttestation as DomainSignedAttestation, + XmssSignature, }, - block::{ - AggregatedSignatureProof as DomainAggregatedSignatureProof, AttestationSignatures, - BlockSignatures as DomainBlockSignatures, ByteListMiB, BytecodeClaim, - SignedBlock as DomainSignedBlock, TypeOneMultiSignature, - }, - primitives::{H256, HashTreeRoot as _}, + primitives::H256, }; use libssz_types::SszVector; use serde::Deserialize; @@ -130,91 +124,11 @@ impl From for DomainSignedAttestation { } } -#[derive(Debug, Clone, Deserialize)] -pub struct SignedBlock { - pub block: Block, - pub signature: BlockSignatures, -} - -impl From for DomainSignedBlock { - fn from(value: SignedBlock) -> Self { - Self { - message: value.block.into(), - signature: value.signature.into(), - } - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct BlockSignatures { - #[serde(rename = "attestationSignatures")] - pub attestation_signatures: Container, - #[serde(rename = "proposerSignature")] - #[serde(deserialize_with = "deser_signature_hex")] - pub proposer_signature: XmssSignature, -} - -impl From for DomainBlockSignatures { - fn from(value: BlockSignatures) -> Self { - let att_sigs: Vec = value - .attestation_signatures - .data - .into_iter() - .map(Into::into) - .collect(); - Self { - attestation_signatures: AttestationSignatures::try_from(att_sigs) - .expect("too many attestation signatures"), - proposer_signature: value.proposer_signature, - } - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct AggregatedSignatureProof { - pub participants: AggregationBits, - #[serde(rename = "proofData")] - pub proof_data: HexByteList, -} - -impl From for DomainAggregatedSignatureProof { - fn from(value: AggregatedSignatureProof) -> Self { - let proof_bytes: Vec = value.proof_data.into(); - Self { - participants: value.participants.into(), - proof_data: ByteListMiB::try_from(proof_bytes).expect("proof data too large"), - } - } -} - -/// Hex-encoded byte list in the fixture format: `{ "data": "0xdeadbeef" }` -#[derive(Debug, Clone, Deserialize)] -pub struct HexByteList { - data: String, -} - -impl From for Vec { - fn from(value: HexByteList) -> Self { - let stripped = value.data.strip_prefix("0x").unwrap_or(&value.data); - hex::decode(stripped).expect("invalid hex in proof data") - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct SignedAggregatedAttestation { - pub data: AttestationData, - pub proof: AggregatedSignatureProof, -} - -impl From for DomainSignedAggregatedAttestation { - fn from(value: SignedAggregatedAttestation) -> Self { - let data: ethlambda_types::attestation::AttestationData = value.data.into(); - let message = data.hash_tree_root(); - let slot = data.slot; - let legacy: DomainAggregatedSignatureProof = value.proof.into(); - Self { - data, - proof: TypeOneMultiSignature::from_legacy(legacy, message, slot, BytecodeClaim::ZERO), - } - } -} +// NOTE: After Phase 3 the legacy `BlockSignatures` / `AttestationSignatures` / +// `AggregatedSignatureProof` containers are removed from the domain, and +// `SignedBlock` now carries a single `proof: ByteListMiB` field. The pinned +// leanSpec fixtures still use the old shape, so SSZ-byte and root assertions +// for `SignedBlock`, `BlockSignatures`, `AggregatedSignatureProof`, and +// `SignedAggregatedAttestation` are intentionally skipped in +// `ssz_spectests.rs::run_ssz_test` until the fixture commit is bumped to the +// Type-1/Type-2 schema. diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index a0f7e0e0..09409fbe 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -282,11 +282,9 @@ mod tests { #[tokio::test] async fn test_get_latest_finalized_block() { use ethlambda_types::{ - attestation::XmssSignature, - block::{Block, BlockBody, BlockSignatures, SignedBlock}, + block::{Block, BlockBody, ByteListMiB, SignedBlock}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, - signature::SIGNATURE_SIZE, }; use libssz::SszEncode; @@ -294,7 +292,7 @@ mod tests { let backend = Arc::new(InMemoryBackend::new()); let mut store = Store::from_anchor_state(backend, state); - // Build a non-genesis signed block with empty body and zero proposer signature. + // Build a non-genesis signed block with empty body and empty proof blob. let block = Block { slot: 1, proposer_index: 0, @@ -305,10 +303,7 @@ mod tests { let block_root = block.header().hash_tree_root(); let signed_block = SignedBlock { message: block, - signature: BlockSignatures { - attestation_signatures: Default::default(), - proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap(), - }, + proof: ByteListMiB::default(), }; // Persist the signed block and mark it as the latest finalized checkpoint. diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 8665a2bf..85db358c 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -11,7 +11,7 @@ use crate::api::{StorageBackend, StorageWriteBatch, Table}; use ethlambda_types::{ attestation::{AttestationData, HashedAttestationData, bits_is_subset}, - block::{Block, BlockBody, BlockHeader, BlockSignatures, SignedBlock, TypeOneMultiSignature}, + block::{Block, BlockBody, BlockHeader, ByteListMiB, SignedBlock, TypeOneMultiSignature}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, signature::ValidatorSignature, @@ -950,16 +950,16 @@ impl Store { batch.commit().expect("commit"); } - /// Get a signed block by combining header, body, and signatures. + /// Get a signed block by combining header, body, and the merged proof. /// /// Returns None if any of the components are not found. - /// Note: Genesis block has no entry in BlockSignatures table. + /// Note: Genesis block has no entry in the `BlockSignatures` table. pub fn get_signed_block(&self, root: &H256) -> Option { let view = self.backend.begin_read().expect("read view"); let key = root.to_ssz(); let header_bytes = view.get(Table::BlockHeaders, &key).expect("get")?; - let sig_bytes = view.get(Table::BlockSignatures, &key).expect("get")?; + let proof_bytes = view.get(Table::BlockSignatures, &key).expect("get")?; let header = BlockHeader::from_ssz_bytes(&header_bytes).expect("valid header"); @@ -972,11 +972,11 @@ impl Store { }; let block = Block::from_header_and_body(header, body); - let signature = BlockSignatures::from_ssz_bytes(&sig_bytes).expect("valid signatures"); + let proof = ByteListMiB::from_ssz_bytes(&proof_bytes).expect("valid block proof"); Some(SignedBlock { message: block, - signature, + proof, }) } @@ -1208,7 +1208,7 @@ impl Store { } } -/// Write block header, body, and signatures onto an existing batch. +/// Write block header, body, and the merged proof blob onto an existing batch. /// /// Returns the deserialized [`Block`] so callers can access fields like /// `slot` and `parent_root` without re-deserializing. @@ -1219,7 +1219,7 @@ fn write_signed_block( ) -> Block { let SignedBlock { message: block, - signature, + proof, } = signed_block; let header = block.header(); @@ -1238,10 +1238,12 @@ fn write_signed_block( .expect("put block body"); } - let sig_entries = vec![(root_bytes, signature.to_ssz())]; + // Store the merged Type-2 proof blob. Table name kept for the column-family + // migration cost; renaming to `BlockProof` is a follow-up. + let proof_entries = vec![(root_bytes, proof.to_ssz())]; batch - .put_batch(Table::BlockSignatures, sig_entries) - .expect("put block signatures"); + .put_batch(Table::BlockSignatures, proof_entries) + .expect("put block proof"); block } From 8fee324d09d933f689a3a05eb445227c9847134d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 12 May 2026 14:55:26 -0300 Subject: [PATCH 4/6] chore: remove comment --- crates/blockchain/src/aggregation.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/crates/blockchain/src/aggregation.rs b/crates/blockchain/src/aggregation.rs index eaffea69..b48390fb 100644 --- a/crates/blockchain/src/aggregation.rs +++ b/crates/blockchain/src/aggregation.rs @@ -395,12 +395,6 @@ pub(crate) fn aggregation_bits_from_validator_indices(bits: &[u64]) -> Aggregati aggregation_bits } -// Type-1 / Type-2 envelope builders moved to -// [`ethlambda_types::block::TypeOneMultiSignature::for_proposer`] and -// [`ethlambda_types::block::TypeTwoMultiSignature::from_type_1s`] so the -// `ethlambda-test-fixtures` crate can share them without depending on the -// blockchain layer. - /// Worker loop — runs on a `spawn_blocking` thread, no store access. /// /// Pulls jobs from the snapshot, runs [`aggregate_job`] for each, and streams From 352954d4a0cbda741abbb67d238edb677169c90e Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Tue, 12 May 2026 16:08:30 -0300 Subject: [PATCH 5/6] refactor(types): hoist MAX_ATTESTATIONS_DATA to ethlambda-types Move the per-block attestation-data cap to ethlambda_types::block as the canonical home, used by the wire types, the blockchain layer, and the test-fixtures crate. Replaces the duplicated literal in ethlambda-blockchain and the mirrored constant in ethlambda-test-fixtures, and derives the TypeOneInfos SSZ-list limit from MAX_ATTESTATIONS_DATA + 1 so the proposer-plus-attestations bound stays in one place. Addresses MegaRedHand review comment on PR #361. --- crates/blockchain/src/lib.rs | 5 +---- crates/common/test-fixtures/src/fork_choice.rs | 10 +--------- crates/common/types/src/block.rs | 11 +++++++++-- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index cd517d80..707af364 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -43,10 +43,7 @@ pub const MILLISECONDS_PER_INTERVAL: u64 = 800; pub const INTERVALS_PER_SLOT: u64 = 5; /// Milliseconds in a slot (derived from interval duration and count). pub const MILLISECONDS_PER_SLOT: u64 = MILLISECONDS_PER_INTERVAL * INTERVALS_PER_SLOT; -/// Maximum number of distinct AttestationData entries per block. -/// -/// See: leanSpec commit 0c9528a (PR #536). -pub const MAX_ATTESTATIONS_DATA: usize = 16; +pub use ethlambda_types::block::MAX_ATTESTATIONS_DATA; /// Future-slot tolerance for gossip attestations, expressed in intervals. /// /// Bounds the clock skew the time check is willing to absorb when admitting a diff --git a/crates/common/test-fixtures/src/fork_choice.rs b/crates/common/test-fixtures/src/fork_choice.rs index d62900a6..75cd810e 100644 --- a/crates/common/test-fixtures/src/fork_choice.rs +++ b/crates/common/test-fixtures/src/fork_choice.rs @@ -9,7 +9,7 @@ use crate::{ }; use ethlambda_types::attestation::XmssSignature; use ethlambda_types::block::{ - ByteListMiB, SignedBlock, TypeOneMultiSignature, TypeTwoMultiSignature, + ByteListMiB, MAX_ATTESTATIONS_DATA, SignedBlock, TypeOneMultiSignature, TypeTwoMultiSignature, }; use ethlambda_types::primitives::{H256, HashTreeRoot as _}; use libssz::SszEncode as _; @@ -17,14 +17,6 @@ use serde::{Deserialize, Deserializer}; use std::collections::HashMap; use std::path::Path; -/// Per-block cap from `ethlambda_blockchain::MAX_ATTESTATIONS_DATA`, mirrored -/// here so the test-fixtures crate stays free of a blockchain-layer dep. Used -/// by [`BlockStepData::to_blank_signed_block`] to fall back to an empty proof -/// for oversized-block test cases (the merged Type-2 metadata list caps at -/// `MAX_ATTESTATIONS_DATA + 1`, which is exactly the proposer + every block -/// attestation; oversized blocks are rejected upstream of proof decoding). -const MAX_ATTESTATIONS_DATA: usize = 16; - // ============================================================================ // Root Structures // ============================================================================ diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index 873d89f0..cab2220e 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -84,12 +84,19 @@ pub struct TypeOneInfo { pub bytecode_claim: BytecodeClaim, } +/// Maximum number of distinct `AttestationData` entries permitted in a single +/// block. Canonical home for the cap shared across `ethlambda-blockchain`, +/// `ethlambda-test-fixtures`, and the wire types in this crate. +/// +/// See: leanSpec commit 0c9528a (PR #536). +pub const MAX_ATTESTATIONS_DATA: usize = 16; + /// SSZ-list of Type-1 info entries packed inside a Type-2 proof. /// /// Holds at most `MAX_ATTESTATIONS_DATA` distinct attestation entries plus one /// for the proposer's own signature. Mirrors upstream -/// `TypeOneInfos.LIMIT = MAX_ATTESTATIONS_DATA + 1` (= 16 + 1). -pub type TypeOneInfos = SszList; +/// `TypeOneInfos.LIMIT = MAX_ATTESTATIONS_DATA + 1`. +pub type TypeOneInfos = SszList; /// A Type-1 single-message proof aggregating signatures from many validators. #[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] From 372fa0a9e302a89ae1546dd0015844d6f5f5fd81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 12 May 2026 16:15:11 -0300 Subject: [PATCH 6/6] chore: remove comments --- crates/common/types/src/block.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index cab2220e..9aaac1d9 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -51,12 +51,6 @@ pub type ByteListMiB = ByteList<1_048_576>; // ============================================================================ // Type-1 / Type-2 multi-signature model // ============================================================================ -// -// New typed multi-signature surface introduced by leanSpec commit -// `anshalshukla/leanSpec@0ab09dd` ("dummy type 1 and type 2 aggregation with -// block proofs"). Defined alongside the legacy `AggregatedSignatureProof` / -// `BlockSignatures` types during the phased migration; consumers will switch -// over in later phases (gossip layer first, then block wire). /// Trusted `Evaluation` field carried inside Type-1 / Type-2 proofs. ///