From 810fa4dba76b46b5ec26faf7ee48cd8cd6663699 Mon Sep 17 00:00:00 2001 From: dicethedev Date: Wed, 10 Jun 2026 06:48:32 +0100 Subject: [PATCH] feat(types): add MultiMessageAggregate --- crates/blockchain/src/block_builder.rs | 7 +- crates/blockchain/src/lib.rs | 16 +-- crates/blockchain/src/reaggregate.rs | 10 +- crates/blockchain/src/store.rs | 18 +-- .../common/test-fixtures/src/fork_choice.rs | 9 +- .../test-fixtures/src/verify_signatures.rs | 8 +- crates/common/types/src/block.rs | 135 +++++++++--------- crates/common/types/tests/ssz_types.rs | 2 +- crates/net/p2p/src/req_resp/handlers.rs | 4 +- crates/net/rpc/src/lib.rs | 8 +- crates/storage/src/store.rs | 10 +- 11 files changed, 106 insertions(+), 121 deletions(-) diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index 5afdbcca..21cbbbef 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -683,7 +683,7 @@ mod tests { use super::*; use ethlambda_types::{ attestation::{AggregatedAttestation, AggregationBits, AttestationData}, - block::{ByteList512KiB, SignedBlock, TypeOneMultiSignature}, + block::{ByteList512KiB, MultiMessageAggregate, SignedBlock, TypeOneMultiSignature}, checkpoint::Checkpoint, state::State, }; @@ -891,8 +891,9 @@ mod tests { // would attach. The actual SNARK can't be built without lean-multisig, // but the size cap (`ByteList512KiB`) bounds the worst case. let _ = signatures; - let proof = - ByteList512KiB::try_from(vec![0xAB; 512 * 1024]).expect("worst-case proof fits in cap"); + let proof = MultiMessageAggregate::new( + ByteList512KiB::try_from(vec![0xAB; 512 * 1024]).expect("worst-case proof fits in cap"), + ); let signed_block = SignedBlock { message: block, proof, diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index e57e2fee..256706d1 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -8,7 +8,7 @@ use ethlambda_types::{ ShortRoot, aggregator::AggregatorController, attestation::{SignedAggregatedAttestation, SignedAttestation}, - block::{ByteList512KiB, SignedBlock}, + block::{ByteList512KiB, MultiMessageAggregate, SignedBlock}, primitives::{H256, HashTreeRoot as _}, signature::{ValidatorPublicKey, ValidatorSignature}, }; @@ -489,11 +489,9 @@ impl BlockChainServer { } merge_inputs.push((vec![proposer_pubkey], proposer_proof_bytes)); - // Merge yields raw lean-multisig Type-2 bytes; wrap them in the - // thin SSZ container the spec uses (`[4-byte offset][type2_wire]`) - // before stashing into the block envelope (leanSpec PR #717 wire - // format). Per-component participants are rederived at verify time - // from `block.body.attestations[i].aggregation_bits` plus + // 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, @@ -503,10 +501,10 @@ impl BlockChainServer { return; } }; - let proof_bytes = match SignedBlock::wrap_merged_proof(merged_bytes.iter().as_slice()) { + let proof = match MultiMessageAggregate::from_bytes(merged_bytes.iter().as_slice()) { Ok(p) => p, Err(err) => { - error!(%slot, %validator_id, %err, "Failed to wrap merged proof envelope"); + error!(%slot, %validator_id, %err, "Failed to build multi-message aggregate"); metrics::inc_block_building_failures(); return; } @@ -515,7 +513,7 @@ impl BlockChainServer { drop(type_one_proofs); let signed_block = SignedBlock { message: block, - proof: proof_bytes, + proof, }; // Process the block locally before publishing diff --git a/crates/blockchain/src/reaggregate.rs b/crates/blockchain/src/reaggregate.rs index b0bdd1b0..53275fac 100644 --- a/crates/blockchain/src/reaggregate.rs +++ b/crates/blockchain/src/reaggregate.rs @@ -118,13 +118,9 @@ pub fn reaggregate_from_block( Err(_) => continue, }; - // Step 1: SNARK-split this attestation's component out of the - // block's merged Type-2 proof. Strip the SSZ container header so - // lean-multisig sees raw bytes. - let Ok(merged_bytes) = signed_block.merged_proof_bytes() else { - debug!("Reaggregation skipped: block proof envelope unusable"); - return Vec::new(); - }; + // 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(); 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 c3842440..5116ea11 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -895,16 +895,7 @@ pub fn verify_block_signatures( u32::try_from(block.slot).map_err(|_| StoreError::SlotOutOfRange(block.slot))?; expected_bindings.push((block_root, block_slot_u32)); - // Strip the thin SSZ container wrapper to recover the raw lean-multisig - // Type-2 bytes the verifier consumes. The spec carries - // `signed_block.proof = [4-byte offset = 4][type2_wire]` so other clients - // can decode through the spec's `TypeTwoMultiSignature` SSZ container - // (leanSpec PR #717). - let merged_bytes = signed_block.merged_proof_bytes().map_err(|_| { - StoreError::AggregateVerificationFailed( - ethlambda_crypto::VerificationError::DeserializationFailed, - ) - })?; + let merged_bytes = signed_block.proof.proof_bytes(); let crypto_start = std::time::Instant::now(); ethlambda_crypto::verify_type_2_signature( @@ -980,7 +971,8 @@ mod tests { use ethlambda_types::{ attestation::{AggregatedAttestation, AggregationBits, AttestationData}, block::{ - AggregatedAttestations, BlockBody, ByteList512KiB, SignedBlock, TypeOneMultiSignature, + AggregatedAttestations, BlockBody, MultiMessageAggregate, SignedBlock, + TypeOneMultiSignature, }, checkpoint::Checkpoint, state::State, @@ -995,8 +987,8 @@ mod tests { fn make_signed_block_proof( _proposer_index: u64, _attestation_proofs: Vec, - ) -> ByteList512KiB { - ByteList512KiB::default() + ) -> MultiMessageAggregate { + MultiMessageAggregate::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 19546a77..fe453f7f 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::{ByteList512KiB, SignedBlock}; +use ethlambda_types::block::{MultiMessageAggregate, SignedBlock}; use ethlambda_types::primitives::H256; use serde::{Deserialize, Deserializer}; use std::collections::HashMap; @@ -157,13 +157,12 @@ impl BlockStepData { /// /// Used by callers that import the block via `on_block_without_verification` /// (fork-choice spec-test runner and Hive test-driver), which skip the - /// crypto verifier entirely. Under the leanSpec PR #717 wire format the - /// merged proof bytes live opaquely on `SignedBlock.proof` and are only - /// inspected by `verify_block_signatures`, so an empty blob suffices. + /// crypto verifier entirely. The merged proof bytes are only inspected by + /// `verify_block_signatures`, so an empty aggregate suffices. pub fn to_blank_signed_block(&self) -> SignedBlock { SignedBlock { message: self.to_block(), - proof: ByteList512KiB::default(), + proof: MultiMessageAggregate::default(), } } } diff --git a/crates/common/test-fixtures/src/verify_signatures.rs b/crates/common/test-fixtures/src/verify_signatures.rs index 5b20d696..496a7c57 100644 --- a/crates/common/test-fixtures/src/verify_signatures.rs +++ b/crates/common/test-fixtures/src/verify_signatures.rs @@ -11,7 +11,7 @@ //! proof: { proof: { data: "0x" } } use crate::{Block, TestInfo, TestState}; -use ethlambda_types::block::SignedBlock; +use ethlambda_types::block::{MultiMessageAggregate, SignedBlock}; use serde::Deserialize; use std::collections::HashMap; use std::fmt; @@ -123,15 +123,15 @@ impl TestSignedBlock { /// Materialize a `SignedBlock` preserving the fixture-supplied merged /// Type-2 proof bytes verbatim. /// - /// The container carries the raw lean-multisig wire, so it gets wrapped - /// into the SSZ-container envelope that `SignedBlock.proof` stores. + /// The container carries the raw lean-multisig wire in the + /// `MultiMessageAggregate` stored by `SignedBlock.proof`. 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 = SignedBlock::wrap_merged_proof(&bytes) + let proof = MultiMessageAggregate::from_bytes(&bytes) .map_err(|_| SignedBlockConvertError::ProofTooLarge(len))?; Ok(SignedBlock { message: self.block.into(), diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index f4e15dc5..788dd845 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -14,12 +14,6 @@ use primitives::HashTreeRoot as _; /// Envelope carrying a block and the single merged proof binding every /// signature it depends on. /// -/// `proof` holds the SSZ-encoded form of a [`TypeTwoMultiSignature`] -/// container whose only field is a `ByteList512KiB` holding the raw -/// `compress_without_pubkeys()` Type-2 merged proof bytes. On the wire the -/// container collapses to `[4-byte offset = 4][type2_wire]` — a thin -/// 4-byte prefix in front of the lean-multisig bytes (leanSpec PR #717). -/// ///
/// /// `HashTreeRoot` is intentionally not derived: consumers never hash a @@ -33,80 +27,71 @@ pub struct SignedBlock { /// The block being signed. pub message: Block, - /// SSZ-encoded `TypeTwoMultiSignature` envelope. Use - /// [`SignedBlock::merged_proof_bytes`] to extract the raw - /// lean-multisig Type-2 bytes inside, or - /// [`SignedBlock::wrap_merged_proof`] when building an envelope from - /// the prover output. + /// Single full-block proof covering attestations and the proposer signature. + pub proof: MultiMessageAggregate, +} + +// 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("proof", &format_args!("<{} bytes>", self.proof.proof.len())) + .finish() + } +} + +/// 512 KiB byte-list cap shared by every block-level / Type-1 proof field. +/// Matches leanSpec PR #717's `ByteList512KiB` SSZ container. +pub type ByteList512KiB = ByteList<524_288>; + +/// A merged proof covering multiple messages with a single proof blob. +/// +/// The proof bytes use lean-multisig's compact public-key-free +/// representation. SSZ encoding this container adds the offset required for +/// its variable-length field. +#[derive(Debug, Default, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)] +pub struct MultiMessageAggregate { + /// Serialized multi-message aggregate proof bytes. pub proof: ByteList512KiB, } -impl SignedBlock { - /// Strip the SSZ-container offset header to return the raw - /// lean-multisig Type-2 merged proof bytes the verifier consumes. - pub fn merged_proof_bytes(&self) -> Result<&[u8], ProofEnvelopeError> { - let bytes = self.proof.iter().as_slice(); - if bytes.len() < 4 { - return Err(ProofEnvelopeError::TruncatedEnvelope); - } - let mut header = [0u8; 4]; - header.copy_from_slice(&bytes[..4]); - let offset = u32::from_le_bytes(header) as usize; - if offset != 4 { - return Err(ProofEnvelopeError::UnexpectedOffset(offset)); - } - Ok(&bytes[4..]) +impl MultiMessageAggregate { + /// Build an aggregate from an already bounded proof byte list. + pub fn new(proof: ByteList512KiB) -> Self { + Self { proof } + } + + /// Copy raw lean-multisig proof bytes into the bounded SSZ container. + pub fn from_bytes(bytes: &[u8]) -> Result { + let len = bytes.len(); + ByteList512KiB::try_from(bytes.to_vec()) + .map(Self::new) + .map_err(|_| MultiMessageAggregateError::ProofTooLarge(len)) } - /// Wrap raw lean-multisig Type-2 bytes into a `SignedBlock.proof` - /// envelope: prepend the 4-byte SSZ offset header so the wire matches - /// the spec's `TypeTwoMultiSignature { proof: ByteList512KiB }` - /// container. - pub fn wrap_merged_proof(type2_wire: &[u8]) -> Result { - let mut wrapped = Vec::with_capacity(4 + type2_wire.len()); - wrapped.extend_from_slice(&4u32.to_le_bytes()); - wrapped.extend_from_slice(type2_wire); - let len = wrapped.len(); - ByteList512KiB::try_from(wrapped).map_err(|_| ProofEnvelopeError::ExceedsCap(len)) + /// Return the raw lean-multisig proof bytes. + pub fn proof_bytes(&self) -> &[u8] { + self.proof.iter().as_slice() } } -/// Errors returned by the [`SignedBlock`] proof-envelope helpers. +/// Errors returned when constructing a [`MultiMessageAggregate`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProofEnvelopeError { - /// Envelope is shorter than the 4-byte SSZ offset header. - TruncatedEnvelope, - /// Offset header is not the expected single-field value `4`. - UnexpectedOffset(usize), - /// Wrapped proof would exceed `ByteList512KiB`'s cap. - ExceedsCap(usize), +pub enum MultiMessageAggregateError { + /// Proof bytes exceed `ByteList512KiB`'s cap. + ProofTooLarge(usize), } -impl core::fmt::Display for ProofEnvelopeError { +impl core::fmt::Display for MultiMessageAggregateError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { - Self::TruncatedEnvelope => f.write_str("block proof envelope truncated"), - Self::UnexpectedOffset(o) => write!(f, "block proof envelope offset {o}, expected 4"), - Self::ExceedsCap(n) => write!(f, "wrapped proof {n} bytes exceeds 512 KiB cap"), + Self::ProofTooLarge(n) => write!(f, "proof {n} bytes exceeds 512 KiB cap"), } } } -impl std::error::Error for ProofEnvelopeError {} - -// 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("proof", &format_args!("<{} bytes>", self.proof.len())) - .finish() - } -} - -/// 512 KiB byte-list cap shared by every block-level / Type-1 proof field. -/// Matches leanSpec PR #717's `ByteList512KiB` SSZ container. -pub type ByteList512KiB = ByteList<524_288>; +impl std::error::Error for MultiMessageAggregateError {} // ============================================================================ // Type-1 multi-signature @@ -118,10 +103,10 @@ pub type ByteList512KiB = ByteList<524_288>; // from the surrounding block body (attestation `data` + slot for body // components, block root + slot for the proposer component). // -// `TypeTwoMultiSignature` has no Rust-side struct: the block carries the -// raw lean-multisig Type-2 bytes directly on `SignedBlock.proof`. 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. +// Component participant bitfields come from +// `block.body.attestations[i].aggregation_bits` (and `block.proposer_index` for +// the trailing proposer entry). /// Maximum number of distinct `AttestationData` entries permitted in a single /// block. Canonical home for the cap shared across `ethlambda-blockchain`, @@ -322,15 +307,27 @@ mod tests { }; let signed = SignedBlock { message: block, - proof: ByteList512KiB::default(), + proof: MultiMessageAggregate::default(), }; let bytes = signed.to_ssz(); let decoded = SignedBlock::from_ssz_bytes(&bytes).expect("decode"); - assert_eq!(decoded.proof.len(), 0); + assert_eq!(decoded.proof.proof.len(), 0); assert_eq!(decoded.message.slot, signed.message.slot); assert_eq!( decoded.message.proposer_index, signed.message.proposer_index ); } + + #[test] + fn multi_message_aggregate_ssz_wraps_proof_bytes() { + let proof_bytes: Vec = (0..64).collect(); + let aggregate = MultiMessageAggregate::from_bytes(&proof_bytes).unwrap(); + + let encoded = aggregate.to_ssz(); + + assert_eq!(&encoded[..4], &4u32.to_le_bytes()); + assert_eq!(&encoded[4..], proof_bytes); + assert_eq!(aggregate.proof_bytes(), proof_bytes); + } } diff --git a/crates/common/types/tests/ssz_types.rs b/crates/common/types/tests/ssz_types.rs index e26cf343..aaf6d350 100644 --- a/crates/common/types/tests/ssz_types.rs +++ b/crates/common/types/tests/ssz_types.rs @@ -126,7 +126,7 @@ impl From for DomainSignedAttestation { // NOTE: After Phase 3 the legacy `BlockSignatures` / `AttestationSignatures` / // `AggregatedSignatureProof` containers are removed from the domain, and -// `SignedBlock` now carries a single `proof: ByteList512KiB` field. The pinned +// `SignedBlock` now carries a single `proof: MultiMessageAggregate` 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 diff --git a/crates/net/p2p/src/req_resp/handlers.rs b/crates/net/p2p/src/req_resp/handlers.rs index 75fd1068..51d251f4 100644 --- a/crates/net/p2p/src/req_resp/handlers.rs +++ b/crates/net/p2p/src/req_resp/handlers.rs @@ -400,7 +400,7 @@ mod tests { use super::*; use ethlambda_storage::{ForkCheckpoints, backend::InMemoryBackend}; use ethlambda_types::{ - block::{Block, BlockBody, ByteList512KiB}, + block::{Block, BlockBody, MultiMessageAggregate}, state::State, }; use std::sync::Arc; @@ -414,7 +414,7 @@ mod tests { state_root: H256::ZERO, body: BlockBody::default(), }, - proof: ByteList512KiB::default(), + proof: MultiMessageAggregate::default(), } } diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index 67977b83..9906bfb9 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -494,7 +494,7 @@ mod tests { #[tokio::test] async fn test_get_latest_finalized_block() { use ethlambda_types::{ - block::{Block, BlockBody, ByteList512KiB, SignedBlock}, + block::{Block, BlockBody, MultiMessageAggregate, SignedBlock}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, }; @@ -515,7 +515,7 @@ mod tests { let block_root = block.header().hash_tree_root(); let signed_block = SignedBlock { message: block, - proof: ByteList512KiB::default(), + proof: MultiMessageAggregate::default(), }; // Persist the signed block and mark it as the latest finalized checkpoint. @@ -555,7 +555,7 @@ mod tests { #[tokio::test] async fn test_get_latest_finalized_block_serves_genesis_with_placeholder_proof() { - use ethlambda_types::block::{ByteList512KiB, SignedBlock}; + use ethlambda_types::block::{MultiMessageAggregate, SignedBlock}; use libssz::SszEncode; // Genesis-anchored store: `init_store` writes the header + state but no @@ -574,7 +574,7 @@ mod tests { .expect("genesis served via get_signed_block"); let expected = SignedBlock { message: genesis_block.message.clone(), - proof: ByteList512KiB::default(), + proof: MultiMessageAggregate::default(), }; let expected_ssz = expected.to_ssz(); diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index f2ae3e66..c0d3079a 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -5,7 +5,9 @@ use crate::api::{StorageBackend, StorageWriteBatch, Table}; use ethlambda_types::{ attestation::{AggregationBits, AttestationData, HashedAttestationData, bits_is_subset}, - block::{Block, BlockBody, BlockHeader, ByteList512KiB, SignedBlock, TypeOneMultiSignature}, + block::{ + Block, BlockBody, BlockHeader, MultiMessageAggregate, SignedBlock, TypeOneMultiSignature, + }, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, signature::ValidatorSignature, @@ -1096,12 +1098,12 @@ impl Store { let proof = match view.get(Table::BlockSignatures, &key).expect("get") { Some(proof_bytes) => { - ByteList512KiB::from_ssz_bytes(&proof_bytes).expect("valid block proof") + MultiMessageAggregate::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 => ByteList512KiB::default(), + None if header.slot == 0 => MultiMessageAggregate::default(), None => return None, }; @@ -2537,7 +2539,7 @@ mod tests { .expect("genesis block must be retrievable with synthetic proof"); assert_eq!(signed.message.slot, 0); - assert_eq!(signed.proof, ByteList512KiB::default()); + assert_eq!(signed.proof, MultiMessageAggregate::default()); } /// The synthesis branch must be confined to the slot-0 anchor: a