From 1cd80ddaf0427847deace75a17264594483e1174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 14 May 2026 10:53:09 -0300 Subject: [PATCH 01/13] feat(crypto): integrate leanMultisig devnet5 (merge, verify, split) Bump lean-multisig + leansig_wrapper to devnet5 HEAD (0242c909) and rewrite ethlambda-crypto on the new Type-1 / Type-2 API: - aggregate_signatures / aggregate_mixed / aggregate_proofs now wrap aggregate_type_1; per-attestation proof bytes are TypeOneMultiSignature compress_without_pubkeys(). - verify_aggregated_signature wraps verify_type_1 with an explicit (message, slot) binding check. - New merge_type_1s_into_type_2 (real cryptographic block-level merge), verify_type_2_signature (binding-checked verifier), and split_type_2_signature (disaggregation). Wire production paths to the real primitives: - propose_block wraps the proposer XMSS as a singleton Type-1 SNARK, resolves per-component pubkeys, and calls merge_type_1s_into_type_2. The SignedBlock.proof envelope now carries the real merged Type-2. - verify_block_signatures runs structural checks first, then crypto- verifies the Type-2 via verify_type_2_signature with bindings derived from the block body and proposer index. - signature_spectests' SKIP_TESTS list emptied: block-level crypto is back, so test_invalid_proposer_signature runs against the real path. TypeTwoMultiSignature::from_type_1s is kept as a documented test-only structural envelope (empty proof bytes) for fast-fail unit tests. Fixes the test signature scheme to the production Dim46 instantiation so SIG_SIZE_FE matches lean-multisig's assertion; the new merge/verify/split round-trip test passes end-to-end in ~13s release. --- Cargo.lock | 95 ++++- crates/blockchain/src/lib.rs | 117 +++++- crates/blockchain/src/store.rs | 78 +++- .../blockchain/tests/signature_spectests.rs | 13 +- crates/common/crypto/Cargo.toml | 4 +- crates/common/crypto/src/lib.rs | 389 +++++++++++++----- crates/common/types/src/block.rs | 17 +- 7 files changed, 536 insertions(+), 177 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4f18935d..d5a8186d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -651,7 +651,7 @@ dependencies = [ [[package]] name = "backend" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=2eb4b9d#2eb4b9d983171139af36749f127dd9890c9109e6" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" dependencies = [ "mt-air", "mt-fiat-shamir", @@ -3466,6 +3466,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indenter" version = "0.3.4" @@ -3729,7 +3748,7 @@ dependencies = [ [[package]] name = "lean-multisig" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=2eb4b9d#2eb4b9d983171139af36749f127dd9890c9109e6" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" dependencies = [ "backend", "clap", @@ -3737,16 +3756,20 @@ dependencies = [ "leansig_wrapper", "rand 0.10.0", "rec_aggregation", + "serde_json", "sub_protocols", + "system-info", "utils", + "zk-alloc", ] [[package]] name = "lean_compiler" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=2eb4b9d#2eb4b9d983171139af36749f127dd9890c9109e6" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" dependencies = [ "backend", + "include_dir", "lean_vm", "pest", "pest_derive", @@ -3759,7 +3782,7 @@ dependencies = [ [[package]] name = "lean_prover" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=2eb4b9d#2eb4b9d983171139af36749f127dd9890c9109e6" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" dependencies = [ "backend", "itertools 0.14.0", @@ -3768,6 +3791,7 @@ dependencies = [ "pest", "pest_derive", "rand 0.10.0", + "serde", "sub_protocols", "tracing", "utils", @@ -3776,7 +3800,7 @@ dependencies = [ [[package]] name = "lean_vm" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=2eb4b9d#2eb4b9d983171139af36749f127dd9890c9109e6" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" dependencies = [ "backend", "itertools 0.14.0", @@ -3832,7 +3856,7 @@ dependencies = [ [[package]] name = "leansig_wrapper" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=2eb4b9d#2eb4b9d983171139af36749f127dd9890c9109e6" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" dependencies = [ "backend", "ethereum_ssz", @@ -4873,7 +4897,7 @@ dependencies = [ [[package]] name = "mt-air" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=2eb4b9d#2eb4b9d983171139af36749f127dd9890c9109e6" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" dependencies = [ "mt-field", "mt-poly", @@ -4882,7 +4906,7 @@ dependencies = [ [[package]] name = "mt-fiat-shamir" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=2eb4b9d#2eb4b9d983171139af36749f127dd9890c9109e6" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" dependencies = [ "mt-field", "mt-koala-bear", @@ -4890,12 +4914,13 @@ dependencies = [ "mt-utils", "rayon", "serde", + "tracing", ] [[package]] name = "mt-field" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=2eb4b9d#2eb4b9d983171139af36749f127dd9890c9109e6" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" dependencies = [ "itertools 0.14.0", "mt-utils", @@ -4910,7 +4935,7 @@ dependencies = [ [[package]] name = "mt-koala-bear" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=2eb4b9d#2eb4b9d983171139af36749f127dd9890c9109e6" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" dependencies = [ "itertools 0.14.0", "mt-field", @@ -4926,7 +4951,7 @@ dependencies = [ [[package]] name = "mt-poly" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=2eb4b9d#2eb4b9d983171139af36749f127dd9890c9109e6" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" dependencies = [ "itertools 0.14.0", "mt-field", @@ -4934,12 +4959,13 @@ dependencies = [ "rand 0.10.0", "rayon", "serde", + "system-info", ] [[package]] name = "mt-sumcheck" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=2eb4b9d#2eb4b9d983171139af36749f127dd9890c9109e6" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" dependencies = [ "mt-air", "mt-fiat-shamir", @@ -4952,7 +4978,7 @@ dependencies = [ [[package]] name = "mt-symetric" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=2eb4b9d#2eb4b9d983171139af36749f127dd9890c9109e6" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" dependencies = [ "mt-field", "mt-koala-bear", @@ -4962,7 +4988,7 @@ dependencies = [ [[package]] name = "mt-utils" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=2eb4b9d#2eb4b9d983171139af36749f127dd9890c9109e6" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" dependencies = [ "serde", ] @@ -4970,7 +4996,7 @@ dependencies = [ [[package]] name = "mt-whir" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=2eb4b9d#2eb4b9d983171139af36749f127dd9890c9109e6" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" dependencies = [ "itertools 0.14.0", "mt-fiat-shamir", @@ -4982,6 +5008,7 @@ dependencies = [ "mt-utils", "rand 0.10.0", "rayon", + "system-info", "tracing", ] @@ -5301,6 +5328,16 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "object" version = "0.37.3" @@ -6469,14 +6506,17 @@ dependencies = [ [[package]] name = "rec_aggregation" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=2eb4b9d#2eb4b9d983171139af36749f127dd9890c9109e6" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" dependencies = [ "backend", + "include_dir", "lean_compiler", "lean_prover", "lean_vm", "leansig_wrapper", "lz4_flex", + "objc2", + "objc2-foundation", "postcard", "rand 0.10.0", "serde", @@ -6484,6 +6524,7 @@ dependencies = [ "sub_protocols", "tracing", "utils", + "zk-alloc", ] [[package]] @@ -7501,7 +7542,7 @@ dependencies = [ [[package]] name = "sub_protocols" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=2eb4b9d#2eb4b9d983171139af36749f127dd9890c9109e6" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" dependencies = [ "backend", "lean_vm", @@ -7592,6 +7633,15 @@ dependencies = [ "libc", ] +[[package]] +name = "system-info" +version = "0.1.0" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" +dependencies = [ + "libc", + "rayon", +] + [[package]] name = "tagptr" version = "0.2.0" @@ -8136,7 +8186,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utils" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=2eb4b9d#2eb4b9d983171139af36749f127dd9890c9109e6" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" dependencies = [ "backend", "tracing", @@ -9093,6 +9143,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zk-alloc" +version = "0.1.0" +source = "git+https://github.com/leanEthereum/leanMultisig.git?rev=0242c909#0242c909260c9e16893baae3004667230429808d" +dependencies = [ + "libc", + "system-info", +] + [[package]] name = "zkhash" version = "0.2.0" diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 707af364..25b87d6a 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -8,8 +8,12 @@ use ethlambda_types::{ ShortRoot, aggregator::AggregatorController, attestation::{SignedAggregatedAttestation, SignedAttestation}, - block::{ByteListMiB, SignedBlock, TypeOneMultiSignature, TypeTwoMultiSignature}, + block::{ + ByteListMiB, BytecodeClaim, SignedBlock, TypeOneInfo, TypeOneInfos, TypeOneMultiSignature, + TypeTwoMultiSignature, + }, primitives::{H256, HashTreeRoot as _}, + signature::{ValidatorPublicKey, ValidatorSignature}, }; use libssz::SszEncode as _; @@ -335,22 +339,105 @@ impl BlockChainServer { return; }; - // 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 = TypeOneMultiSignature::for_proposer( - validator_id, - proposer_proof_bytes, - block_root, - slot, - ); + // Assemble SignedBlock: wrap the proposer's raw XMSS signature into a + // singleton Type-1 SNARK, then merge it with every attestation Type-1 + // into the block's single Type-2 proof (real lean-multisig devnet5 + // cryptography, replacing the structural-only stub used before). + let head_state = self.store.head_state(); + let validators = &head_state.validators; + let Some(proposer_validator) = validators.get(validator_id as usize) else { + error!(%slot, %validator_id, "Proposer index out of range when assembling block"); + metrics::inc_block_building_failures(); + return; + }; + let Ok(proposer_pubkey) = proposer_validator.get_proposal_pubkey().inspect_err( + |err| error!(%slot, %validator_id, %err, "Failed to decode proposer proposal pubkey"), + ) else { + metrics::inc_block_building_failures(); + return; + }; + + let Ok(proposer_validator_signature) = + ValidatorSignature::from_bytes(&proposer_signature).inspect_err(|err| { + error!(%slot, %validator_id, %err, "Failed to decode proposer signature bytes") + }) + else { + metrics::inc_block_building_failures(); + return; + }; + let Ok(proposer_t1_bytes) = ethlambda_crypto::aggregate_signatures( + vec![proposer_pubkey.clone()], + vec![proposer_validator_signature], + &block_root, + slot as u32, + ) + .inspect_err( + |err| error!(%slot, %validator_id, %err, "Failed to wrap proposer signature as Type-1"), + ) else { + metrics::inc_block_building_failures(); + return; + }; + let proposer_t1 = + TypeOneMultiSignature::for_proposer(validator_id, proposer_t1_bytes, block_root, slot); + + // Resolve pubkeys per Type-1 component for merge_many_type_1. Attestation + // components use each participant's attestation_pubkey; the trailing + // proposer component uses the single proposal_pubkey. + let mut merge_inputs: Vec<(Vec, ByteListMiB)> = + Vec::with_capacity(type_one_proofs.len() + 1); + let mut resolve_failed = false; + for t1 in &type_one_proofs { + let mut pubkeys = Vec::new(); + for vid in t1.participant_indices() { + let Some(validator) = validators.get(vid as usize) else { + error!(%slot, %validator_id, vid, "Participant out of range while resolving pubkeys"); + resolve_failed = true; + break; + }; + match validator.get_attestation_pubkey() { + Ok(pk) => pubkeys.push(pk), + Err(err) => { + error!(%slot, %validator_id, vid, %err, "Failed to decode attestation pubkey"); + resolve_failed = true; + break; + } + } + } + if resolve_failed { + break; + } + merge_inputs.push((pubkeys, t1.proof.clone())); + } + if resolve_failed { + metrics::inc_block_building_failures(); + return; + } + merge_inputs.push((vec![proposer_pubkey], proposer_t1.proof.clone())); + + let Ok(merged_proof_bytes) = ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) + .inspect_err( + |err| error!(%slot, %validator_id, %err, "Failed to merge Type-1s into Type-2"), + ) + else { + metrics::inc_block_building_failures(); + return; + }; + let mut all_proofs = type_one_proofs; all_proofs.push(proposer_t1); - let merged = TypeTwoMultiSignature::from_type_1s(all_proofs); - let proof_bytes = ByteListMiB::try_from(merged.to_ssz()) - .expect("merged Type-2 proof fits in ByteListMiB"); + let infos: Vec = all_proofs.into_iter().map(|t1| t1.info).collect(); + let Ok(merged_infos) = TypeOneInfos::try_from(infos) else { + error!(%slot, %validator_id, "Too many Type-1 infos for Type-2 envelope"); + metrics::inc_block_building_failures(); + return; + }; + let merged_envelope = TypeTwoMultiSignature { + info: merged_infos, + bytecode_claim: BytecodeClaim::ZERO, + proof: merged_proof_bytes, + }; + let proof_bytes = ByteListMiB::try_from(merged_envelope.to_ssz()) + .expect("merged Type-2 envelope fits in ByteListMiB"); let signed_block = SignedBlock { message: block, proof: proof_bytes, diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index a7d0561f..1eadbfac 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -17,7 +17,7 @@ use ethlambda_types::{ }, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, - signature::ValidatorSignature, + signature::{ValidatorPublicKey, ValidatorSignature}, state::State, }; use libssz::SszDecode as _; @@ -1006,9 +1006,9 @@ fn compact_attestations( /// one previously-uncovered validator; partially-overlapping participants /// between selected proofs are allowed. `compact_attestations` later feeds /// these proofs as children to `aggregate_proofs`, which delegates to -/// `xmss_aggregate` — that function tracks duplicate pubkeys across -/// children via its `dup_pub_keys` machinery, so overlap is supported by -/// the underlying aggregation scheme. +/// lean-multisig devnet5 `aggregate_type_1` — that function tracks duplicate +/// pubkeys across children via its `dup_pub_keys` machinery, so overlap is +/// supported by the underlying aggregation scheme. /// /// Each selected proof is appended to `selected` paired with its /// corresponding AggregatedAttestation. @@ -1189,16 +1189,13 @@ fn build_block( Ok((final_block, aggregated_signatures, post_checkpoints)) } -/// Structural verification of a signed block's merged Type-2 proof. +/// Full verification of a signed block's merged Type-2 proof. /// -/// Phase 3 of the Type-1 / Type-2 aggregation migration replaces the per- -/// attestation `verify_aggregated_signature` plus standalone proposer-signature -/// check with a structural alignment check on the merged Type-2 blob: the -/// `info` list must hold one entry per block-body attestation plus one -/// trailing entry for the proposer. Cryptographic verification of each Type-1 -/// still happens at gossip ingestion (`on_gossip_aggregated_attestation`); the -/// block-level crypto path returns once `lean_multisig` exposes a real -/// merged-proof verification primitive. +/// Structural pre-checks (fast fail) ensure the merged proof's `info` list lines +/// up with the block body (one entry per attestation plus a trailing proposer +/// entry; messages, slots, and participants match what the body declares). +/// On success, the lean-multisig devnet5 `verify_type_2` primitive runs the +/// SNARK verifier over the merged proof bytes against the resolved pubkey set. /// /// Exposed publicly so RPC handlers (notably the Hive test-driver /// `verify_signatures/run` endpoint) can run the exact same verification path @@ -1228,7 +1225,8 @@ pub fn verify_block_signatures( let num_validators = validators.len() as u64; // Per-attestation entries: messages, slots, and participants must mirror - // the block body. The crypto binding for each is already checked at gossip. + // the block body. The crypto leg (verify_type_2 below) checks the actual + // multi-signature binding once structural alignment holds. for (attestation, info) in attestations.iter().zip(merged.info.iter()) { if attestation.aggregation_bits != info.participants { return Err(StoreError::ParticipantsMismatch); @@ -1264,12 +1262,60 @@ pub fn verify_block_signatures( return Err(StoreError::InvalidValidatorIndex); } + let structural_elapsed = total_start.elapsed(); + + // Resolve pubkeys per Type-2 component for verify_type_2. Attestation + // components use each participant's attestation_pubkey; the trailing + // proposer component uses the proposal_pubkey of `block.proposer_index`. + let mut pubkeys_per_component: Vec> = + Vec::with_capacity(merged.info.len()); + let mut expected_bindings: Vec<(H256, u32)> = Vec::with_capacity(merged.info.len()); + + for attestation in attestations.iter() { + let mut pubkeys = Vec::new(); + for vid in validator_indices(&attestation.aggregation_bits) { + let validator = validators + .get(vid as usize) + .ok_or(StoreError::InvalidValidatorIndex)?; + let pk = validator + .get_attestation_pubkey() + .map_err(|_| StoreError::PubkeyDecodingFailed(vid))?; + pubkeys.push(pk); + } + pubkeys_per_component.push(pubkeys); + let slot_u32 = u32::try_from(attestation.data.slot) + .map_err(|_| StoreError::ProposerSignatureVerificationFailed)?; + expected_bindings.push((attestation.data.hash_tree_root(), slot_u32)); + } + + let proposer_validator = validators + .get(block.proposer_index as usize) + .ok_or(StoreError::InvalidValidatorIndex)?; + let proposer_pubkey = proposer_validator + .get_proposal_pubkey() + .map_err(|_| StoreError::PubkeyDecodingFailed(block.proposer_index))?; + pubkeys_per_component.push(vec![proposer_pubkey]); + let block_slot_u32 = + u32::try_from(block.slot).map_err(|_| StoreError::ProposerSignatureVerificationFailed)?; + expected_bindings.push((block_root, block_slot_u32)); + + let crypto_start = std::time::Instant::now(); + ethlambda_crypto::verify_type_2_signature( + &merged.proof, + pubkeys_per_component, + &expected_bindings, + ) + .map_err(StoreError::AggregateVerificationFailed)?; + let crypto_elapsed = crypto_start.elapsed(); + let total_elapsed = total_start.elapsed(); info!( slot = block.slot, attestation_count = attestations.len(), + ?structural_elapsed, + ?crypto_elapsed, ?total_elapsed, - "Block proof structural check" + "Block Type-2 proof verified" ); Ok(()) @@ -1729,7 +1775,7 @@ mod tests { /// least one previously-uncovered validator. The greedy prefers the /// largest proof first, then picks additional proofs whose coverage /// extends `covered`. The resulting overlap is handled downstream by - /// `aggregate_proofs` → `xmss_aggregate` (which tracks duplicate pubkeys + /// `aggregate_proofs` → `aggregate_type_1` (which tracks duplicate pubkeys /// across children via its `dup_pub_keys` machinery). #[test] fn extend_proofs_greedily_allows_overlap_when_it_adds_coverage() { diff --git a/crates/blockchain/tests/signature_spectests.rs b/crates/blockchain/tests/signature_spectests.rs index dc59384d..9f30f51d 100644 --- a/crates/blockchain/tests/signature_spectests.rs +++ b/crates/blockchain/tests/signature_spectests.rs @@ -15,16 +15,9 @@ 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"]; +/// Block-level crypto verification is now wired through lean-multisig devnet5's +/// `verify_type_2`, so every fixture is exercised against the real primitive. +const SKIP_TESTS: &[&str] = &[]; fn run(path: &Path) -> datatest_stable::Result<()> { let tests = VerifySignaturesTestVector::from_file(path)?; diff --git a/crates/common/crypto/Cargo.toml b/crates/common/crypto/Cargo.toml index 4002997d..dc5ba718 100644 --- a/crates/common/crypto/Cargo.toml +++ b/crates/common/crypto/Cargo.toml @@ -12,9 +12,9 @@ version.workspace = true [dependencies] ethlambda-types.workspace = true -lean-multisig = { git = "https://github.com/leanEthereum/leanMultisig.git", rev = "2eb4b9d" } +lean-multisig = { git = "https://github.com/leanEthereum/leanMultisig.git", rev = "0242c909" } # leansig_wrapper provides XmssPublicKey/XmssSignature types used by lean-multisig's public API -leansig_wrapper = { git = "https://github.com/leanEthereum/leanMultisig.git", rev = "2eb4b9d" } +leansig_wrapper = { git = "https://github.com/leanEthereum/leanMultisig.git", rev = "0242c909" } leansig.workspace = true thiserror.workspace = true diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index 006ef2b0..294dfbcd 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -6,12 +6,18 @@ use ethlambda_types::{ signature::{ValidatorPublicKey, ValidatorSignature}, }; use lean_multisig::{ - AggregatedXMSS, ProofError, setup_prover, setup_verifier, xmss_aggregate, - xmss_verify_aggregation, + ProofError, TypeOneMultiSignature as LMType1, TypeTwoMultiSignature as LMType2, + aggregate_type_1, merge_many_type_1, setup_prover, setup_verifier, split_type_2, verify_type_1, + verify_type_2, }; use leansig_wrapper::{XmssPublicKey as LeanSigPubKey, XmssSignature as LeanSigSignature}; use thiserror::Error; +/// log(1/rate) for the WHIR commitment scheme used inside lean-multisig. +/// 2 matches the devnet-4 cross-client convention (zeam, ream, grandine, lantern +/// all use 2); the leanMultisig devnet5 examples also use 2 for recursion. +const LOG_INV_RATE: usize = 2; + // Lazy initialization for prover and verifier setup static PROVER_INIT: Once = Once::new(); static VERIFIER_INIT: Once = Once::new(); @@ -43,6 +49,18 @@ pub enum AggregationError { #[error("need at least 2 children for recursive aggregation, got {0}")] InsufficientChildren(usize), + + #[error("component count ({components}) does not match pubkey-set count ({pubkey_sets})")] + ComponentPubkeyMismatch { + components: usize, + pubkey_sets: usize, + }, + + #[error("split index {index} out of bounds for type-2 with {components} components")] + SplitIndexOutOfBounds { index: usize, components: usize }, + + #[error("prover failure: {0}")] + ProverFailure(String), } /// Error type for signature verification operations. @@ -56,25 +74,74 @@ pub enum VerificationError { #[error("verification failed: {0}")] ProofError(#[from] ProofError), + + #[error( + "(message, slot) mismatch: proof binds {got_slot}/{got_msg:?}, expected {expected_slot}/{expected_msg:?}" + )] + BindingMismatch { + expected_msg: H256, + expected_slot: u32, + got_msg: H256, + got_slot: u32, + }, + + #[error("component count ({components}) does not match pubkey-set count ({pubkey_sets})")] + ComponentPubkeyMismatch { + components: usize, + pubkey_sets: usize, + }, + + #[error("type-2 binds {got} components but {expected} were expected")] + Type2ComponentCountMismatch { expected: usize, got: usize }, } -/// Aggregate multiple XMSS signatures into a single proof. -/// -/// This function takes a set of public keys and their corresponding signatures, -/// all signing the same message at the same slot, and produces a single -/// aggregated proof that can be verified more efficiently than checking -/// each signature individually. -/// -/// # Arguments +// ===================================================================== +// Helpers +// ===================================================================== + +fn into_lean_pubkeys(pubkeys: Vec) -> Vec { + pubkeys + .into_iter() + .map(ValidatorPublicKey::into_inner) + .collect() +} + +/// Decompress a stored Type-1 proof (without-pubkeys form) into a native +/// `TypeOneMultiSignature` by attaching the resolved validator pubkeys. +fn decompress_type1( + pubkeys: Vec, + proof_bytes: &ByteListMiB, + index: usize, +) -> Result { + let lean_pks = into_lean_pubkeys(pubkeys); + LMType1::decompress_without_pubkeys(proof_bytes.iter().as_slice(), lean_pks) + .ok_or(AggregationError::ChildDeserializationFailed(index)) +} + +fn compress_type1_to_byte_list(sig: &LMType1) -> Result { + let serialized = sig.compress_without_pubkeys(); + let len = serialized.len(); + ByteListMiB::try_from(serialized).map_err(|_| AggregationError::ProofTooBig(len)) +} + +fn compress_type2_to_byte_list(sig: &LMType2) -> Result { + let serialized = sig.compress_without_pubkeys(); + let len = serialized.len(); + ByteListMiB::try_from(serialized).map_err(|_| AggregationError::ProofTooBig(len)) +} + +// ===================================================================== +// Type-1 aggregation (single message, single slot) +// ===================================================================== + +/// Aggregate multiple XMSS signatures into a single Type-1 proof. /// -/// * `public_keys` - The public keys of the validators who signed -/// * `signatures` - The signatures from each validator (must match public_keys order) -/// * `message` - The 32-byte message that was signed -/// * `slot` - The slot in which the signatures were created +/// Equivalent to `aggregate_type_1([], raw_xmss, ...)` in lean-multisig. /// -/// # Returns +/// All signatures must bind to the same `(message, slot)` pair. /// -/// The serialized aggregated proof as `ByteListMiB`, or an error if aggregation fails. +/// Returns the lean-multisig `TypeOneMultiSignature::compress_without_pubkeys()` +/// bytes, packed as `ByteListMiB` for the on-wire SSZ proof field. pub fn aggregate_signatures( public_keys: Vec, signatures: Vec, @@ -87,8 +154,6 @@ pub fn aggregate_signatures( signatures.len(), )); } - - // Handle empty input if public_keys.is_empty() { return Err(AggregationError::EmptyInput); } @@ -101,28 +166,19 @@ pub fn aggregate_signatures( .map(|(pk, sig)| (pk.into_inner(), sig.into_inner())) .collect(); - // log_inv_rate=2 matches the devnet-4 cross-client convention (zeam, ream, - // grandine, lantern's c-leanvm-xmss all use 2). Ethlambda previously - // hardcoded 1, which produced proofs incompatible with every other client. - let (_sorted_pubkeys, aggregate) = xmss_aggregate(&[], raw_xmss, &message.0, slot, 2); + let proof = aggregate_type_1(&[], raw_xmss, message.0, slot, LOG_INV_RATE) + .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; - serialize_aggregate(aggregate) + compress_type1_to_byte_list(&proof) } -/// Aggregate both existing proofs (children) and raw XMSS signatures in a single call. -/// -/// This is the spec's gossip-time mixed aggregation: existing proofs from previous -/// rounds are fed as children, and only genuinely new signatures go as `raw_xmss`. -/// This avoids re-aggregating from scratch each round and keeps proof trees shallow. -/// -/// Requires at least one raw signature OR at least 2 children. A lone child proof -/// is already valid and needs no further aggregation. +/// Aggregate both existing Type-1 proofs (children) and raw XMSS signatures. /// -/// # Panics +/// Existing Type-1s are reused as recursive children; raw XMSS are mixed in. +/// All inputs must bind to the same `(message, slot)`. /// -/// Panics if any deserialized child proof is cryptographically invalid (e.g., was -/// produced for a different message or slot). This is an upstream constraint of -/// `xmss_aggregate`. +/// Requires at least one raw signature OR at least 2 children. A lone child is +/// already a valid Type-1; further aggregation is wasted work. pub fn aggregate_mixed( children: Vec<(Vec, ByteListMiB)>, raw_public_keys: Vec, @@ -136,22 +192,17 @@ pub fn aggregate_mixed( raw_signatures.len(), )); } - - // Need at least one raw signature OR at least 2 children to merge. if raw_public_keys.is_empty() && children.len() < 2 { return Err(AggregationError::InsufficientChildren(children.len())); } ensure_prover_ready(); - // Split deserialized children into parallel Vecs so we can borrow pubkey - // slices (required by xmss_aggregate's tuple type) while moving the large - // AggregatedXMSS values into the children list without cloning. `pks_list` - // must outlive `children_refs`. - let (pks_list, aggs): (Vec>, Vec) = - deserialize_children(children)?.into_iter().unzip(); - let children_refs: Vec<(&[LeanSigPubKey], AggregatedXMSS)> = - pks_list.iter().map(Vec::as_slice).zip(aggs).collect(); + let children_native: Vec = children + .into_iter() + .enumerate() + .map(|(i, (pubkeys, proof_bytes))| decompress_type1(pubkeys, &proof_bytes, i)) + .collect::>()?; let raw_xmss: Vec<(LeanSigPubKey, LeanSigSignature)> = raw_public_keys .into_iter() @@ -159,20 +210,16 @@ pub fn aggregate_mixed( .map(|(pk, sig)| (pk.into_inner(), sig.into_inner())) .collect(); - let (_sorted_pubkeys, aggregate) = - xmss_aggregate(&children_refs, raw_xmss, &message.0, slot, 2); + let proof = aggregate_type_1(&children_native, raw_xmss, message.0, slot, LOG_INV_RATE) + .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; - serialize_aggregate(aggregate) + compress_type1_to_byte_list(&proof) } -/// Recursively aggregate multiple already-aggregated proofs into one. +/// Recursively aggregate two or more already-aggregated Type-1 proofs into one. /// -/// Each child is a `(public_keys, proof_data)` pair where `public_keys` are the -/// attestation public keys of the validators covered by that child proof, and -/// `proof_data` is the serialized `AggregatedXMSS`. At least 2 children are required. -/// -/// This is used during block building to compact multiple proofs sharing the same -/// `AttestationData` into a single merged proof (leanSpec PR #510). +/// All children must bind to the same `(message, slot)`. Used during block +/// building to compact multiple proofs sharing an `AttestationData`. pub fn aggregate_proofs( children: Vec<(Vec, ByteListMiB)>, message: &H256, @@ -184,57 +231,24 @@ pub fn aggregate_proofs( ensure_prover_ready(); - // See `aggregate_mixed` for why this unzip-and-rezip dance is needed. - let (pks_list, aggs): (Vec>, Vec) = - deserialize_children(children)?.into_iter().unzip(); - let children_refs: Vec<(&[LeanSigPubKey], AggregatedXMSS)> = - pks_list.iter().map(Vec::as_slice).zip(aggs).collect(); - - let (_sorted_pubkeys, aggregate) = xmss_aggregate(&children_refs, vec![], &message.0, slot, 2); - - serialize_aggregate(aggregate) -} - -/// Deserialize child proofs from `(public_keys, proof_bytes)` pairs into -/// lean-multisig types. -fn deserialize_children( - children: Vec<(Vec, ByteListMiB)>, -) -> Result, AggregatedXMSS)>, AggregationError> { - children + let children_native: Vec = children .into_iter() .enumerate() - .map(|(i, (pubkeys, proof_data))| { - let lean_pks: Vec = - pubkeys.into_iter().map(|pk| pk.into_inner()).collect(); - let aggregate = AggregatedXMSS::deserialize(proof_data.iter().as_slice()) - .ok_or(AggregationError::ChildDeserializationFailed(i))?; - Ok((lean_pks, aggregate)) - }) - .collect() -} + .map(|(i, (pubkeys, proof_bytes))| decompress_type1(pubkeys, &proof_bytes, i)) + .collect::>()?; -/// Serialize an `AggregatedXMSS` into the `ByteListMiB` wire format. -fn serialize_aggregate(aggregate: AggregatedXMSS) -> Result { - let serialized = aggregate.serialize(); - let serialized_len = serialized.len(); - ByteListMiB::try_from(serialized).map_err(|_| AggregationError::ProofTooBig(serialized_len)) + let proof = aggregate_type_1(&children_native, vec![], message.0, slot, LOG_INV_RATE) + .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; + + compress_type1_to_byte_list(&proof) } -/// Verify an aggregated signature proof. -/// -/// This function verifies that a set of validators (identified by their public keys) -/// all signed the same message at the same slot. -/// -/// # Arguments +/// Verify a Type-1 aggregated signature proof. /// -/// * `proof_data` - The serialized aggregated proof -/// * `public_keys` - The public keys of the validators who allegedly signed -/// * `message` - The 32-byte message that was allegedly signed -/// * `slot` - The slot in which the signatures were allegedly created +/// Cryptographically verifies that every `public_key` signed `message` at `slot`. /// -/// # Returns -/// -/// `Ok(())` if verification succeeds, or an error describing why it failed. +/// The verifier checks the bound `(message, slot)` matches what the caller +/// expects, defending against proofs reused from other binding contexts. pub fn verify_aggregated_signature( proof_data: &ByteListMiB, public_keys: Vec, @@ -243,30 +257,153 @@ pub fn verify_aggregated_signature( ) -> Result<(), VerificationError> { ensure_verifier_ready(); - // Convert public keys - let lean_pubkeys: Vec = public_keys + let lean_pubkeys = into_lean_pubkeys(public_keys); + let sig = LMType1::decompress_without_pubkeys(proof_data.iter().as_slice(), lean_pubkeys) + .ok_or(VerificationError::DeserializationFailed)?; + + if sig.info.without_pubkeys.message != message.0 || sig.info.without_pubkeys.slot != slot { + return Err(VerificationError::BindingMismatch { + expected_msg: *message, + expected_slot: slot, + got_msg: H256(sig.info.without_pubkeys.message), + got_slot: sig.info.without_pubkeys.slot, + }); + } + + verify_type_1(&sig)?; + Ok(()) +} + +// ===================================================================== +// Type-2 merge / verify / split (block-level merged proofs) +// ===================================================================== + +/// Merge many independent Type-1 multi-signatures into a single Type-2 proof. +/// +/// Each input is `(participant_pubkeys, type_1_proof_bytes)` where the bytes +/// are the `compress_without_pubkeys()` form of a `TypeOneMultiSignature`. +/// +/// The returned blob is the `compress_without_pubkeys()` form of the resulting +/// `TypeTwoMultiSignature`. A verifier decoding it back needs the per-component +/// pubkey sets in the same order. +pub fn merge_type_1s_into_type_2( + type_1s: Vec<(Vec, ByteListMiB)>, +) -> Result { + if type_1s.is_empty() { + return Err(AggregationError::EmptyInput); + } + + ensure_prover_ready(); + + let type_1s_native: Vec = type_1s + .into_iter() + .enumerate() + .map(|(i, (pubkeys, proof_bytes))| decompress_type1(pubkeys, &proof_bytes, i)) + .collect::>()?; + + let merged = merge_many_type_1(type_1s_native, LOG_INV_RATE) + .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; + + compress_type2_to_byte_list(&merged) +} + +/// Verify a Type-2 merged proof against the per-component expected bindings. +/// +/// The verifier re-derives each component's `(message, slot, pubkeys)` from the +/// caller-supplied lists, checks they match what the proof binds, and then runs +/// the inner SNARK verifier. +pub fn verify_type_2_signature( + proof_data: &ByteListMiB, + pubkeys_per_component: Vec>, + expected_bindings: &[(H256, u32)], +) -> Result<(), VerificationError> { + if expected_bindings.len() != pubkeys_per_component.len() { + return Err(VerificationError::ComponentPubkeyMismatch { + components: expected_bindings.len(), + pubkey_sets: pubkeys_per_component.len(), + }); + } + + ensure_verifier_ready(); + + let pubkeys_per_info: Vec> = pubkeys_per_component .into_iter() - .map(ValidatorPublicKey::into_inner) + .map(into_lean_pubkeys) .collect(); - // Deserialize the aggregate proof - let aggregate = AggregatedXMSS::deserialize(proof_data.iter().as_slice()) + let sig = LMType2::decompress_without_pubkeys(proof_data.iter().as_slice(), pubkeys_per_info) .ok_or(VerificationError::DeserializationFailed)?; - // Verify using lean-multisig - xmss_verify_aggregation(lean_pubkeys, &aggregate, &message.0, slot)?; + if sig.info.len() != expected_bindings.len() { + return Err(VerificationError::Type2ComponentCountMismatch { + expected: expected_bindings.len(), + got: sig.info.len(), + }); + } + + for (idx, ((expected_msg, expected_slot), info)) in + expected_bindings.iter().zip(sig.info.iter()).enumerate() + { + if info.without_pubkeys.message != expected_msg.0 + || info.without_pubkeys.slot != *expected_slot + { + return Err(VerificationError::BindingMismatch { + expected_msg: *expected_msg, + expected_slot: *expected_slot, + got_msg: H256(info.without_pubkeys.message), + got_slot: info.without_pubkeys.slot, + }); + } + let _ = idx; // index reserved for richer diagnostics if needed + } + verify_type_2(&sig)?; Ok(()) } +/// Split (disaggregate) a Type-2 merged proof into a single Type-1 proof for +/// the component at `index`. Generates a fresh SNARK; expensive. +/// +/// Returns the `compress_without_pubkeys()` form of the resulting Type-1. +pub fn split_type_2_signature( + proof_data: &ByteListMiB, + pubkeys_per_component: Vec>, + index: usize, +) -> Result { + ensure_prover_ready(); + + if index >= pubkeys_per_component.len() { + return Err(AggregationError::SplitIndexOutOfBounds { + index, + components: pubkeys_per_component.len(), + }); + } + + let pubkeys_per_info: Vec> = pubkeys_per_component + .into_iter() + .map(into_lean_pubkeys) + .collect(); + + let type_2 = + LMType2::decompress_without_pubkeys(proof_data.iter().as_slice(), pubkeys_per_info) + .ok_or(AggregationError::ChildDeserializationFailed(0))?; + + let component = split_type_2(type_2, index, LOG_INV_RATE) + .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; + + compress_type1_to_byte_list(&component) +} + #[cfg(test)] mod tests { use super::*; use leansig::{serialization::Serializable, signature::SignatureScheme}; use rand::{SeedableRng, rngs::StdRng}; - // The signature scheme type used in ethlambda-types - type LeanSignatureScheme = leansig::signature::generalized_xmss::instantiations_poseidon_top_level::lifetime_2_to_the_32::hashing_optimized::SIGTopLevelTargetSumLifetime32Dim64Base8; + // The signature scheme type used in ethlambda-types (Dim46 to match + // production validator keys; lean-multisig's `aggregate_type_1` hard-codes + // `SIG_SIZE_FE = 7 + (V + LOG_LIFETIME) * 8 = 631` for V=46). + type LeanSignatureScheme = leansig::signature::generalized_xmss::instantiations_aborting::lifetime_2_to_the_32::SchemeAbortingTargetSumLifetime32Dim46Base8; /// Generate a test keypair and sign a message. /// @@ -408,4 +545,40 @@ mod tests { "Verification should have failed with wrong slot" ); } + + /// End-to-end Type-2 round-trip: produce two Type-1s (different (msg, slot)), + /// merge them into a Type-2, verify the Type-2, then split out one component + /// and verify it as a Type-1. + #[test] + #[ignore = "too slow"] + fn test_type_2_merge_verify_split_round_trip() { + let msg_a = H256::from([0x11u8; 32]); + let msg_b = H256::from([0x22u8; 32]); + let slot_a: u32 = 7; + let slot_b: u32 = 11; + + let (pk_a, sig_a) = generate_keypair_and_sign(101, 5, slot_a, &msg_a); + let (pk_b, sig_b) = generate_keypair_and_sign(102, 5, slot_b, &msg_b); + + let pa = aggregate_signatures(vec![pk_a.clone()], vec![sig_a], &msg_a, slot_a).unwrap(); + let pb = aggregate_signatures(vec![pk_b.clone()], vec![sig_b], &msg_b, slot_b).unwrap(); + + let merged = + merge_type_1s_into_type_2(vec![(vec![pk_a.clone()], pa), (vec![pk_b.clone()], pb)]) + .expect("merge"); + + verify_type_2_signature( + &merged, + vec![vec![pk_a.clone()], vec![pk_b.clone()]], + &[(msg_a, slot_a), (msg_b, slot_b)], + ) + .expect("verify type-2"); + + let split = + split_type_2_signature(&merged, vec![vec![pk_a.clone()], vec![pk_b.clone()]], 0) + .expect("split"); + + verify_aggregated_signature(&split, vec![pk_a.clone()], &msg_a, slot_a) + .expect("verify split"); + } } diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index 9aaac1d9..e18d55bb 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -169,14 +169,15 @@ impl TypeOneMultiSignature { } impl TypeTwoMultiSignature { - /// Merge a list of Type-1 single-message proofs into a single Type-2 - /// multi-message proof. 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 until the `lean_multisig_py` bindings ship - /// real cryptographic merging. Block-level signature verification stays - /// structural-only in the meantime, and per-attestation crypto verification - /// continues to run at gossip ingestion. + /// Build a metadata-preserving Type-2 envelope with EMPTY merged proof + /// bytes. Useful for tests that exercise the structural-only fast-fail leg + /// of `verify_block_signatures` (participants mismatch, missing entries…) + /// without paying the lean-multisig SNARK cost. + /// + /// Production block production uses + /// [`ethlambda_crypto::merge_type_1s_into_type_2`] to produce a real + /// cryptographic Type-2 proof; do not use this helper for any path that + /// actually verifies the merged proof. pub fn from_type_1s(type_1s: Vec) -> Self { let infos: Vec = type_1s.into_iter().map(|t1| t1.info).collect(); let info = TypeOneInfos::try_from(infos) From 2c9dec0cd09b9c913863db8c755ababf52b48af6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 14 May 2026 11:19:08 -0300 Subject: [PATCH 02/13] refactor(types): align Type-1/Type-2 envelope with leanSpec PR #717 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slim the on-wire shape to match the spec PR's "aggregated block proof" model. The verifier no longer relies on duplicated metadata inside the proof envelope — message, slot, and bytecode_claim live solely on the block body it already trusts. TypeOneInfo: {message, slot, participants, bytecode_claim} → {participants, proof} TypeOneMultiSignature: {info, proof} (info.proof == outer proof) TypeTwoMultiSignature: {info, bytecode_claim, proof} → {info, proof} The per-component Type-1 bytes now live inside TypeOneInfo.proof, so a node receiving a block can recover a standalone Type-1 (e.g. for fork- choice payload caching or re-broadcast) without running a fresh SNARK. on_block_core wires this through: known_aggregated_payloads entries now carry the real per-attestation Type-1 wire, not an empty placeholder. verify_block_signatures drops the duplicate (message, slot) cross-check on each info entry; bindings are rederived from block.body.attestations + (block_root, block.slot) and handed to verify_type_2_signature. Disaggregation API swapped from split_type_2_signature(index) to split_type_2_by_message, mirroring leanSpec's split_by_msg primitive. The wrapper decompresses the Type-2, finds the unique component whose internal native message matches, and delegates to lean_multisig's split_type_2. New AggregationError::UnknownMessage / MultipleMessages variants replace the now-unused SplitIndexOutOfBounds. build_block_caps_attestation_data_entries: bump synthetic PROOF_SIZE down from 253 KiB → 50 KiB to reflect that the envelope now carries N+1 copies of the per-component bytes, and to roughly match an expected lean-multisig devnet5 Type-1 SNARK size. --- crates/blockchain/src/aggregation.rs | 12 +- crates/blockchain/src/lib.rs | 6 +- crates/blockchain/src/store.rs | 96 +++++------- .../blockchain/tests/forkchoice_spectests.rs | 8 +- crates/common/crypto/src/lib.rs | 47 ++++-- .../common/test-fixtures/src/fork_choice.rs | 13 +- .../test-fixtures/src/verify_signatures.rs | 22 +-- crates/common/types/src/block.rs | 138 ++++++++---------- crates/net/rpc/src/test_driver.rs | 7 +- crates/storage/src/store.rs | 6 +- 10 files changed, 140 insertions(+), 215 deletions(-) diff --git a/crates/blockchain/src/aggregation.rs b/crates/blockchain/src/aggregation.rs index b48390fb..78af91ec 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::{ByteListMiB, BytecodeClaim, TypeOneInfo, TypeOneMultiSignature}, + block::{ByteListMiB, TypeOneMultiSignature}, primitives::H256, signature::{ValidatorPublicKey, ValidatorSignature}, state::Validator, @@ -290,15 +290,7 @@ pub fn aggregate_job(job: AggregationJob) -> Option { participants.dedup(); let aggregation_bits = aggregation_bits_from_validator_indices(&participants); - let proof = TypeOneMultiSignature { - info: TypeOneInfo { - message: data_root, - slot: job.slot, - participants: aggregation_bits, - bytecode_claim: BytecodeClaim::ZERO, - }, - proof: proof_data, - }; + let proof = TypeOneMultiSignature::new(aggregation_bits, proof_data); metrics::observe_aggregated_proof_size(proof.proof.len()); Some(AggregatedGroupOutput { diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 25b87d6a..a3784d03 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -9,7 +9,7 @@ use ethlambda_types::{ aggregator::AggregatorController, attestation::{SignedAggregatedAttestation, SignedAttestation}, block::{ - ByteListMiB, BytecodeClaim, SignedBlock, TypeOneInfo, TypeOneInfos, TypeOneMultiSignature, + ByteListMiB, SignedBlock, TypeOneInfo, TypeOneInfos, TypeOneMultiSignature, TypeTwoMultiSignature, }, primitives::{H256, HashTreeRoot as _}, @@ -377,8 +377,7 @@ impl BlockChainServer { metrics::inc_block_building_failures(); return; }; - let proposer_t1 = - TypeOneMultiSignature::for_proposer(validator_id, proposer_t1_bytes, block_root, slot); + let proposer_t1 = TypeOneMultiSignature::for_proposer(validator_id, proposer_t1_bytes); // Resolve pubkeys per Type-1 component for merge_many_type_1. Attestation // components use each participant's attestation_pubkey; the trailing @@ -433,7 +432,6 @@ impl BlockChainServer { }; let merged_envelope = TypeTwoMultiSignature { info: merged_infos, - bytecode_claim: BytecodeClaim::ZERO, proof: merged_proof_bytes, }; let proof_bytes = ByteListMiB::try_from(merged_envelope.to_ssz()) diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 1eadbfac..3e6fc3d3 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -12,8 +12,8 @@ use ethlambda_types::{ HashedAttestationData, SignedAggregatedAttestation, SignedAttestation, validator_indices, }, block::{ - AggregatedAttestations, Block, BlockBody, ByteListMiB, BytecodeClaim, SignedBlock, - TypeOneInfo, TypeOneMultiSignature, TypeTwoMultiSignature, + AggregatedAttestations, Block, BlockBody, SignedBlock, TypeOneMultiSignature, + TypeTwoMultiSignature, }, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, @@ -514,14 +514,10 @@ fn on_block_core( // 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. + // The merged Type-2 envelope carries per-component Type-1 proof bytes + // inside `info[i].proof` (leanSpec PR #717), so we can recover real + // standalone Type-1s for each attestation without running a fresh SNARK + // and feed them into the payload buffer for downstream re-aggregation. let aggregated_attestations = &block.body.attestations; let merged = TypeTwoMultiSignature::from_ssz_bytes(signed_block.proof.iter().as_slice()) .map_err(|_| StoreError::ProposerSignatureDecodingFailed)?; @@ -542,7 +538,7 @@ fn on_block_core( let hashed = HashedAttestationData::new(att.data.clone()); let type_one = TypeOneMultiSignature { info: info.clone(), - proof: ByteListMiB::default(), + proof: info.proof.clone(), }; known_entries.push((hashed, type_one)); // Count each participating validator as a valid attestation. @@ -980,15 +976,7 @@ fn compact_attestations( let merged_proof_data = aggregate_proofs(children, &data_root, slot) .map_err(StoreError::SignatureAggregationFailed)?; - 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_proof = TypeOneMultiSignature::new(merged_bits.clone(), merged_proof_data); let merged_att = AggregatedAttestation { aggregation_bits: merged_bits, data, @@ -1224,22 +1212,14 @@ pub fn verify_block_signatures( let validators = &state.validators; let num_validators = validators.len() as u64; - // Per-attestation entries: messages, slots, and participants must mirror - // the block body. The crypto leg (verify_type_2 below) checks the actual - // multi-signature binding once structural alignment holds. + // Per-attestation entries: participant bitfields must mirror the block + // body. The signed message and slot live on the body, not on the proof + // envelope (leanSpec PR #717), so they're rederived below for the crypto + // binding check rather than cross-checked here. 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); @@ -1247,13 +1227,9 @@ pub fn verify_block_signatures( } } - // Trailing proposer entry: single bit for `block.proposer_index`, - // message equals the block root, slot matches the block slot. + // Trailing proposer entry: single bit for `block.proposer_index`. let proposer_info = &merged.info[attestations.len()]; let block_root = block.hash_tree_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); @@ -1264,7 +1240,8 @@ pub fn verify_block_signatures( let structural_elapsed = total_start.elapsed(); - // Resolve pubkeys per Type-2 component for verify_type_2. Attestation + // Resolve pubkeys per Type-2 component for verify_type_2 and rederive the + // expected (message, slot) bindings from the block body. Attestation // components use each participant's attestation_pubkey; the trailing // proposer component uses the proposal_pubkey of `block.proposer_index`. let mut pubkeys_per_component: Vec> = @@ -1381,20 +1358,15 @@ mod tests { 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. + /// proposer Type-1 into the SSZ-encoded merged Type-2 blob. fn make_signed_block_proof( proposer_index: u64, - block_root: H256, - slot: u64, attestation_proofs: Vec, ) -> ByteListMiB { let mut all = attestation_proofs; all.push(TypeOneMultiSignature::for_proposer( proposer_index, ByteListMiB::default(), - block_root, - slot, )); let merged = TypeTwoMultiSignature::from_type_1s(all); ByteListMiB::try_from(merged.to_ssz()).expect("merged proof fits in ByteListMiB") @@ -1445,12 +1417,9 @@ mod tests { }; 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 mismatching_t1 = TypeOneMultiSignature::empty(proof_bits); + let _ = block_root; // proof envelope no longer carries the block root + let proof = make_signed_block_proof(0, vec![mismatching_t1]); let signed_block = SignedBlock { message: block, @@ -1477,7 +1446,12 @@ mod tests { use libssz_types::SszList; const MAX_PAYLOAD_SIZE: usize = 10 * 1024 * 1024; // 10 MiB (spec limit) - const PROOF_SIZE: usize = 253 * 1024; // ~253 KB realistic XMSS proof + // The Type-2 envelope now embeds each per-component Type-1 proof in + // info[i].proof (leanSpec PR #717), so the per-component size has to + // budget for `MAX_ATTESTATIONS_DATA + 1` copies fitting in the 1 MiB + // ByteListMiB cap. 50 KiB per component is roughly what a real + // lean-multisig devnet5 Type-1 SNARK weighs in at. + const PROOF_SIZE: usize = 50 * 1024; const NUM_VALIDATORS: usize = 50; const NUM_PAYLOAD_ENTRIES: usize = 50; @@ -1541,7 +1515,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::new(bits, data_root, att_data.slot, proof_data); + let proof = TypeOneMultiSignature::new(bits, proof_data); aggregated_payloads.insert(data_root, (att_data, vec![proof])); } @@ -1566,8 +1540,7 @@ mod tests { ); // 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 proof = make_signed_block_proof(proposer_index, signatures); let signed_block = SignedBlock { message: block, proof, @@ -1605,10 +1578,10 @@ mod tests { } /// 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) + /// metadata. Only the participant bitfield matters for the pipeline tests + /// below; the proof envelope no longer carries a slot or message. + fn make_type_one_proof(bits: AggregationBits, _slot: u64) -> TypeOneMultiSignature { + TypeOneMultiSignature::empty(bits) } #[test] @@ -1744,13 +1717,12 @@ mod tests { }; let block_root = block.hash_tree_root(); let att_root = att_data.hash_tree_root(); + let _ = (block_root, att_root); // unused under the slim wire format 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), + TypeOneMultiSignature::empty(bits_a), + TypeOneMultiSignature::empty(bits_b), ], ); let signed_block = SignedBlock { diff --git a/crates/blockchain/tests/forkchoice_spectests.rs b/crates/blockchain/tests/forkchoice_spectests.rs index 7be7fb12..41401cda 100644 --- a/crates/blockchain/tests/forkchoice_spectests.rs +++ b/crates/blockchain/tests/forkchoice_spectests.rs @@ -119,12 +119,8 @@ 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::new( - proof_fixture.participants.into(), - data.hash_tree_root(), - data.slot, - proof_data, - ); + let proof = + TypeOneMultiSignature::new(proof_fixture.participants.into(), proof_data); let aggregated = SignedAggregatedAttestation { data, proof }; let result = store::on_gossip_aggregated_attestation(&mut store, aggregated); diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index 294dfbcd..31eec96b 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -56,8 +56,11 @@ pub enum AggregationError { pubkey_sets: usize, }, - #[error("split index {index} out of bounds for type-2 with {components} components")] - SplitIndexOutOfBounds { index: usize, components: usize }, + #[error("split-by-message target not found in type-2 components")] + UnknownMessage, + + #[error("split-by-message target matched multiple components")] + MultipleMessages, #[error("prover failure: {0}")] ProverFailure(String), @@ -362,23 +365,20 @@ pub fn verify_type_2_signature( } /// Split (disaggregate) a Type-2 merged proof into a single Type-1 proof for -/// the component at `index`. Generates a fresh SNARK; expensive. +/// the component bound to `message`. Generates a fresh SNARK; expensive. /// -/// Returns the `compress_without_pubkeys()` form of the resulting Type-1. -pub fn split_type_2_signature( +/// Mirrors leanSpec PR #717 `TypeTwoMultiSignature.split_by_msg`: the caller +/// supplies the expected message (an attestation data root or the block +/// root) and the wrapper locates the unique matching component inside the +/// decompressed proof. Returns the `compress_without_pubkeys()` form of the +/// resulting Type-1. +pub fn split_type_2_by_message( proof_data: &ByteListMiB, pubkeys_per_component: Vec>, - index: usize, + message: &H256, ) -> Result { ensure_prover_ready(); - if index >= pubkeys_per_component.len() { - return Err(AggregationError::SplitIndexOutOfBounds { - index, - components: pubkeys_per_component.len(), - }); - } - let pubkeys_per_info: Vec> = pubkeys_per_component .into_iter() .map(into_lean_pubkeys) @@ -388,6 +388,18 @@ pub fn split_type_2_signature( LMType2::decompress_without_pubkeys(proof_data.iter().as_slice(), pubkeys_per_info) .ok_or(AggregationError::ChildDeserializationFailed(0))?; + let matches: Vec = type_2 + .info + .iter() + .enumerate() + .filter_map(|(i, info)| (info.without_pubkeys.message == message.0).then_some(i)) + .collect(); + let index = match matches.as_slice() { + [i] => *i, + [] => return Err(AggregationError::UnknownMessage), + _ => return Err(AggregationError::MultipleMessages), + }; + let component = split_type_2(type_2, index, LOG_INV_RATE) .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; @@ -574,9 +586,12 @@ mod tests { ) .expect("verify type-2"); - let split = - split_type_2_signature(&merged, vec![vec![pk_a.clone()], vec![pk_b.clone()]], 0) - .expect("split"); + let split = split_type_2_by_message( + &merged, + vec![vec![pk_a.clone()], vec![pk_b.clone()]], + &msg_a, + ) + .expect("split"); verify_aggregated_signature(&split, vec![pk_a.clone()], &msg_a, slot_a) .expect("verify split"); diff --git a/crates/common/test-fixtures/src/fork_choice.rs b/crates/common/test-fixtures/src/fork_choice.rs index 75cd810e..2351a0da 100644 --- a/crates/common/test-fixtures/src/fork_choice.rs +++ b/crates/common/test-fixtures/src/fork_choice.rs @@ -11,7 +11,7 @@ use ethlambda_types::attestation::XmssSignature; use ethlambda_types::block::{ ByteListMiB, MAX_ATTESTATIONS_DATA, SignedBlock, TypeOneMultiSignature, TypeTwoMultiSignature, }; -use ethlambda_types::primitives::{H256, HashTreeRoot as _}; +use ethlambda_types::primitives::H256; use libssz::SszEncode as _; use serde::{Deserialize, Deserializer}; use std::collections::HashMap; @@ -168,7 +168,6 @@ impl BlockStepData { /// those scenarios. pub fn to_blank_signed_block(&self) -> SignedBlock { let block = self.to_block(); - let block_root = block.hash_tree_root(); let proof = if block.body.attestations.len() > MAX_ATTESTATIONS_DATA { ByteListMiB::default() } else { @@ -176,20 +175,12 @@ impl BlockStepData { .body .attestations .iter() - .map(|att| { - TypeOneMultiSignature::empty( - att.aggregation_bits.clone(), - att.data.hash_tree_root(), - att.data.slot, - ) - }) + .map(|att| TypeOneMultiSignature::empty(att.aggregation_bits.clone())) .collect(); let mut all = attestation_proofs; all.push(TypeOneMultiSignature::for_proposer( block.proposer_index, ByteListMiB::default(), - block_root, - block.slot, )); let merged = TypeTwoMultiSignature::from_type_1s(all); ByteListMiB::try_from(merged.to_ssz()).expect("merged proof fits in ByteListMiB") diff --git a/crates/common/test-fixtures/src/verify_signatures.rs b/crates/common/test-fixtures/src/verify_signatures.rs index d9a44f28..96866083 100644 --- a/crates/common/test-fixtures/src/verify_signatures.rs +++ b/crates/common/test-fixtures/src/verify_signatures.rs @@ -9,7 +9,6 @@ use ethlambda_types::attestation::{AggregationBits as EthAggregationBits, XmssSi use ethlambda_types::block::{ ByteListMiB, SignedBlock, TypeOneMultiSignature, TypeTwoMultiSignature, }; -use ethlambda_types::primitives::HashTreeRoot as _; use libssz::SszEncode as _; use serde::Deserialize; use std::collections::HashMap; @@ -73,7 +72,6 @@ pub struct TestSignedBlock { impl From for SignedBlock { fn from(value: TestSignedBlock) -> Self { let block: ethlambda_types::block::Block = value.block.into(); - let block_root = block.hash_tree_root(); let proposer_proof = ByteListMiB::try_from(value.signature.proposer_signature.to_vec()) .expect("XMSS signature fits in ByteListMiB"); @@ -82,10 +80,9 @@ impl From for SignedBlock { .attestation_signatures .data .into_iter() - .zip(block.body.attestations.iter()) - .map(|(att_sig, att)| { + .map(|att_sig| { let participants: EthAggregationBits = att_sig.participants.into(); - TypeOneMultiSignature::empty(participants, att.data.hash_tree_root(), att.data.slot) + TypeOneMultiSignature::empty(participants) }) .collect(); @@ -93,8 +90,6 @@ impl From for SignedBlock { all.push(TypeOneMultiSignature::for_proposer( block.proposer_index, proposer_proof, - block_root, - block.slot, )); let merged = TypeTwoMultiSignature::from_type_1s(all); let proof = ByteListMiB::try_from(merged.to_ssz()) @@ -148,7 +143,6 @@ impl TestSignedBlock { /// them through `verify_block_signatures`). pub fn try_into_signed_block_with_proofs(self) -> Result { let block: ethlambda_types::block::Block = self.block.into(); - let block_root = block.hash_tree_root(); let proposer_proof = ByteListMiB::try_from(self.signature.proposer_signature.to_vec()) .expect("XMSS signature fits in ByteListMiB"); @@ -157,9 +151,8 @@ impl TestSignedBlock { .attestation_signatures .data .into_iter() - .zip(block.body.attestations.iter()) .enumerate() - .map(|(index, (att_sig, att))| { + .map(|(index, att_sig)| { let participants: EthAggregationBits = att_sig.participants.into(); let raw = &att_sig.proof_data.data; let stripped = raw.strip_prefix("0x").unwrap_or(raw); @@ -172,12 +165,7 @@ impl TestSignedBlock { let len = bytes.len(); let proof_data = ByteListMiB::try_from(bytes) .map_err(|_| SignedBlockConvertError::ProofTooLarge { index, len })?; - Ok(TypeOneMultiSignature::new( - participants, - att.data.hash_tree_root(), - att.data.slot, - proof_data, - )) + Ok(TypeOneMultiSignature::new(participants, proof_data)) }) .collect::>()?; @@ -189,8 +177,6 @@ impl TestSignedBlock { all.push(TypeOneMultiSignature::for_proposer( block.proposer_index, proposer_proof, - block_root, - block.slot, )); let merged = TypeTwoMultiSignature::from_type_1s(all); let proof = ByteListMiB::try_from(merged.to_ssz()) diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index e18d55bb..5de61f3a 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -51,31 +51,26 @@ pub type ByteListMiB = ByteList<1_048_576>; // ============================================================================ // Type-1 / Type-2 multi-signature model // ============================================================================ - -/// 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. +// +// Wire format mirrors leanSpec PR #717: the proof envelope carries only what +// the verifier cannot rederive from the block body. `message` / `slot` / +// `bytecode_claim` are intentionally absent — the verifier reconstructs each +// component's binding from the block-body attestation it sits next to (plus +// the block root + slot for the proposer entry). + +/// Per-component metadata for a Type-1 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. +/// Holds the participant bitfield and the per-component proof bytes in +/// compact no-pubkeys form. Inside a Type-2 envelope, `proof` is the standalone +/// Type-1 wire for this single component, enabling cheap disaggregation +/// without running a fresh SNARK. #[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, + /// Standalone Type-1 proof bytes (`compress_without_pubkeys`) for this + /// component. Used by split-by-msg and by re-broadcast paths. + pub proof: ByteListMiB, } /// Maximum number of distinct `AttestationData` entries permitted in a single @@ -93,73 +88,66 @@ pub const MAX_ATTESTATIONS_DATA: usize = 16; pub type TypeOneInfos = SszList; /// A Type-1 single-message proof aggregating signatures from many validators. +/// +/// The outer `proof` field is the canonical aggregated proof bytes; `info.proof` +/// holds the same bytes (kept aligned so a Type-1 embedded inside a Type-2's +/// info list reads identically standalone). `message` and `slot` live on the +/// caller-side block body, not on this envelope. #[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] pub struct TypeOneMultiSignature { - /// Message, slot, participants, and trusted bytecode claim. + /// Per-component participant bitfield plus the standalone proof bytes. pub info: TypeOneInfo, - /// Raw aggregated proof bytes (`ExecutionProof` on the Rust side). + /// Aggregated proof bytes in compact no-pubkeys representation. 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. +/// `signed_block.proof` carries the SSZ-encoded form of this container. The +/// `info` list enumerates per-component (participants, standalone Type-1 +/// proof bytes); messages and slots are reconstructed at verify time from the +/// block body. #[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] pub struct TypeTwoMultiSignature { - /// Per-message metadata, one entry per merged Type-1 proof. + /// Per-component 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). + /// Merged proof bytes in compact no-pubkeys representation. pub proof: ByteListMiB, } impl TypeOneMultiSignature { - /// Build a Type-1 proof with the given participants, message, slot and - /// raw proof bytes. - pub fn new( - participants: AggregationBits, - message: H256, - slot: u64, - proof_data: ByteListMiB, - ) -> Self { + /// Build a Type-1 proof carrying the given participant bitfield and the + /// aggregated proof bytes. + pub fn new(participants: AggregationBits, proof_data: ByteListMiB) -> Self { Self { info: TypeOneInfo { - message, - slot, participants, - bytecode_claim: BytecodeClaim::ZERO, + proof: proof_data.clone(), }, proof: proof_data, } } - /// 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()) + /// Build a Type-1 proof carrying the given participants and EMPTY proof + /// bytes. Useful as a placeholder in fork-choice payload caches where only + /// the participant set is needed; cannot drive a real Type-2 merge or + /// pass cryptographic verification. + pub fn empty(participants: AggregationBits) -> Self { + Self::new(participants, SszList::new()) } - /// Wrap a proposer's XMSS signature over a block root as a singleton Type-1. + /// Wrap a proposer's Type-1 proof bytes with the singleton participant set. /// - /// Used by block production and test fixtures to fold the proposer's - /// signature into the block-level Type-2 merged proof. - pub fn for_proposer( - proposer_index: u64, - proposer_signature: ByteListMiB, - block_root: H256, - slot: u64, - ) -> Self { + /// The bytes must be a real aggregated Type-1 over the proposer's XMSS + /// signature (e.g. from `ethlambda_crypto::aggregate_signatures`), not + /// raw XMSS bytes — `verify_type_2` rejects raw-XMSS placeholders. + pub fn for_proposer(proposer_index: u64, proposer_proof_bytes: ByteListMiB) -> Self { let mut participants = AggregationBits::with_length(proposer_index as usize + 1) .expect("validator index fits"); participants .set(proposer_index as usize, true) .expect("index within capacity"); - Self::new(participants, block_root, slot, proposer_signature) + Self::new(participants, proposer_proof_bytes) } /// Returns the validator indices that are set in the participants bitfield. @@ -169,10 +157,10 @@ impl TypeOneMultiSignature { } impl TypeTwoMultiSignature { - /// Build a metadata-preserving Type-2 envelope with EMPTY merged proof - /// bytes. Useful for tests that exercise the structural-only fast-fail leg - /// of `verify_block_signatures` (participants mismatch, missing entries…) - /// without paying the lean-multisig SNARK cost. + /// Build a Type-2 envelope from a list of Type-1 components with EMPTY + /// merged proof bytes. Useful for tests that exercise the structural + /// fast-fail leg of `verify_block_signatures` (participants mismatch, + /// missing entries, …) without paying the lean-multisig SNARK cost. /// /// Production block production uses /// [`ethlambda_crypto::merge_type_1s_into_type_2`] to produce a real @@ -184,7 +172,6 @@ impl TypeTwoMultiSignature { .expect("type-1 infos within MAX_ATTESTATIONS_DATA + 1 limit"); Self { info, - bytecode_claim: BytecodeClaim::ZERO, proof: ByteListMiB::default(), } } @@ -290,10 +277,8 @@ mod tests { 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]), + proof: ByteListMiB::try_from((0..32u8).collect::>()).unwrap(), } } @@ -302,13 +287,11 @@ mod tests { 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() ); + assert_eq!(decoded.proof.to_vec(), info.proof.to_vec()); } #[test] @@ -321,44 +304,41 @@ mod tests { 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); + assert_eq!( + decoded.info.participants.as_bytes(), + sig.info.participants.as_bytes() + ); } #[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]), + proof: ByteListMiB::try_from(vec![i as u8; 16]).unwrap(), }) .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); + assert_eq!(got.participants.as_bytes(), want.participants.as_bytes()); + assert_eq!(got.proof.to_vec(), want.proof.to_vec()); } } #[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, + .map(|_| TypeOneInfo { participants: sample_bits(1, &[0]), - bytecode_claim: H256([0u8; 32]), + proof: ByteListMiB::default(), }) .collect(); assert!(TypeOneInfos::try_from(too_many).is_err()); diff --git a/crates/net/rpc/src/test_driver.rs b/crates/net/rpc/src/test_driver.rs index 61cff59a..30b9d015 100644 --- a/crates/net/rpc/src/test_driver.rs +++ b/crates/net/rpc/src/test_driver.rs @@ -430,12 +430,7 @@ fn apply_step(store: &mut Store, step: ForkChoiceStep) -> Result<(), String> { .map_err(|err| format!("aggregated proof data too large: {err:?}"))?; let data: ethlambda_types::attestation::AttestationData = att.data.into(); let aggregated = SignedAggregatedAttestation { - proof: TypeOneMultiSignature::new( - participants, - data.hash_tree_root(), - data.slot, - proof_data, - ), + proof: TypeOneMultiSignature::new(participants, proof_data), data, }; store::on_gossip_aggregated_attestation(store, aggregated).map_err(|e| e.to_string()) diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 85db358c..1dc08b1e 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -1628,7 +1628,7 @@ mod tests { fn make_proof() -> TypeOneMultiSignature { use ethlambda_types::attestation::AggregationBits; - TypeOneMultiSignature::empty(AggregationBits::new(), H256::ZERO, 0) + TypeOneMultiSignature::empty(AggregationBits::new()) } /// Create a proof with a specific validator bit set (distinct participants). @@ -1636,7 +1636,7 @@ mod tests { use ethlambda_types::attestation::AggregationBits; let mut bits = AggregationBits::with_length(vid + 1).unwrap(); bits.set(vid, true).unwrap(); - TypeOneMultiSignature::empty(bits, H256::ZERO, 0) + TypeOneMultiSignature::empty(bits) } /// Create a proof with bits set for every validator in `vids`. @@ -1647,7 +1647,7 @@ mod tests { for &v in vids { bits.set(v as usize, true).unwrap(); } - TypeOneMultiSignature::empty(bits, H256::ZERO, 0) + TypeOneMultiSignature::empty(bits) } fn make_att_data(slot: u64) -> AttestationData { From 53611365ae7638bec2566001a8578ebcd0711111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 14 May 2026 13:01:13 -0300 Subject: [PATCH 03/13] fix(blockchain): strip per-component Type-1 bytes from Type-2 envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leanSpec PR #717 embeds each component's standalone Type-1 SNARK inside `TypeOneInfo.proof` so split-by-msg can recover a Type-1 without running a fresh SNARK. With realistic lean-multisig devnet5 Type-1 sizes (~225 KiB observed locally) bundling N+1 copies of those bytes plus the merged Type-2 proof blows past the 1 MiB `ByteListMiB` cap on the outer `SignedBlock.proof` envelope: the proposer panicked with `OverCapacity { max: 1048576, got: 1354324 }` already at slot 6 with 5 attestations. Strip `info[i].proof` to empty bytes when packing the Type-2 envelope in `propose_block`. The merged proof bytes alone still bind the full signature set, so `verify_block_signatures` keeps working. Recovery of a standalone Type-1 is still possible via `split_type_2_by_message`, which is SNARK-backed regardless. On_block_core stops trying to read per-component bytes back; the fork-choice payload-buffer entries it inserts are info-only, matching their pre-PR-717 shape. Verified with a 2-node ethlambda-only devnet run over 21 slots: every block (attestation_count 0..7) is `Block Type-2 proof verified`, `crypto_elapsed ~38 ms`, no panic. Finalization didn't advance with only 2 validators in a single committee, but that's orthogonal — fork-choice reorgs blocking 2/3+ vote accumulation, not the wire format. --- crates/blockchain/src/lib.rs | 23 ++++++++++++++++++++--- crates/blockchain/src/store.rs | 14 ++++++++------ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index a3784d03..b2837cd0 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -424,7 +424,20 @@ impl BlockChainServer { let mut all_proofs = type_one_proofs; all_proofs.push(proposer_t1); - let infos: Vec = all_proofs.into_iter().map(|t1| t1.info).collect(); + // Strip the per-component Type-1 proof bytes when packing the Type-2 + // envelope. leanSpec PR #717 stores them inside `info[i].proof` to + // enable cheap split-without-SNARK, but with realistic + // lean-multisig devnet5 Type-1 sizes (~225 KiB) bundling N+1 copies + // overflows the 1 MiB `ByteListMiB` cap on the outer envelope. + // The merged proof bytes alone still verify the full binding; + // `split_type_2_by_message` is the SNARK-backed recovery path. + let infos: Vec = all_proofs + .into_iter() + .map(|t1| TypeOneInfo { + participants: t1.info.participants, + proof: ByteListMiB::default(), + }) + .collect(); let Ok(merged_infos) = TypeOneInfos::try_from(infos) else { error!(%slot, %validator_id, "Too many Type-1 infos for Type-2 envelope"); metrics::inc_block_building_failures(); @@ -434,8 +447,12 @@ impl BlockChainServer { info: merged_infos, proof: merged_proof_bytes, }; - let proof_bytes = ByteListMiB::try_from(merged_envelope.to_ssz()) - .expect("merged Type-2 envelope fits in ByteListMiB"); + let Ok(proof_bytes) = ByteListMiB::try_from(merged_envelope.to_ssz()).inspect_err( + |err| error!(%slot, %validator_id, ?err, "Merged Type-2 envelope exceeds ByteListMiB"), + ) else { + metrics::inc_block_building_failures(); + return; + }; let signed_block = SignedBlock { message: block, proof: proof_bytes, diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 3e6fc3d3..dadf7dd5 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -12,7 +12,7 @@ use ethlambda_types::{ HashedAttestationData, SignedAggregatedAttestation, SignedAttestation, validator_indices, }, block::{ - AggregatedAttestations, Block, BlockBody, SignedBlock, TypeOneMultiSignature, + AggregatedAttestations, Block, BlockBody, ByteListMiB, SignedBlock, TypeOneMultiSignature, TypeTwoMultiSignature, }, checkpoint::Checkpoint, @@ -514,10 +514,12 @@ fn on_block_core( // Process block body attestations and feed them into the payload buffer // so fork choice's LMD GHOST overlay can see block-only votes. // - // The merged Type-2 envelope carries per-component Type-1 proof bytes - // inside `info[i].proof` (leanSpec PR #717), so we can recover real - // standalone Type-1s for each attestation without running a fresh SNARK - // and feed them into the payload buffer for downstream re-aggregation. + // The merged Type-2 envelope carries info-only (participants) entries + // for each component to keep the on-wire envelope under the 1 MiB cap. + // Standalone Type-1 proof bytes are not recoverable from a block; + // downstream re-aggregation has to come from the gossip channel or be + // SNARK-split with `split_type_2_by_message`. Entries we insert here + // are info-only, used only for fork-choice vote bookkeeping. let aggregated_attestations = &block.body.attestations; let merged = TypeTwoMultiSignature::from_ssz_bytes(signed_block.proof.iter().as_slice()) .map_err(|_| StoreError::ProposerSignatureDecodingFailed)?; @@ -538,7 +540,7 @@ fn on_block_core( let hashed = HashedAttestationData::new(att.data.clone()); let type_one = TypeOneMultiSignature { info: info.clone(), - proof: info.proof.clone(), + proof: ByteListMiB::default(), }; known_entries.push((hashed, type_one)); // Count each participating validator as a valid attestation. From 3199e7d088f56833d44cc10469234143c5464183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 14 May 2026 15:17:57 -0300 Subject: [PATCH 04/13] feat(blockchain): gate Type-2 SNARK behind --crypto-merge-t1-into-t2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Block production previously ran two SNARKs on the actor thread (proposer Type-1 wrap + merge_many_type_1). Each currently takes ~5 s, so the tick handler at interval 0 stalls past interval 1 and validators never publish attestations beyond slot 0. With no attestations after the first slot, justification stays at 0 forever and the chain cannot finalize. Add a `--crypto-merge-t1-into-t2` flag (default `false`) that gates both SNARKs: * off (default): proposer ships a metadata-only Type-2 envelope (per-component `participants` + empty proof bytes), `propose_block` stays fast, interval-1 attestations run on time. `verify_block_signatures` detects the empty SNARK and skips `verify_type_2`, keeping the existing structural checks. Per-attestation crypto still runs at gossip ingestion. * on: full devnet5 cryptography (real proposer Type-1 + merge_many_type_1 + verify_type_2 on import). Default off until the SNARK work is moved off the actor thread — spawn_blocking + result message, mirroring how the aggregator already runs `aggregate_job` on a worker thread. Single-node devnet (8 validators on ethlambda_0 with --is-aggregator, flag default-off) finalizes: Fork Choice Tree: Finalized: slot 4 | root 1376f65e Justified: slot 5 | root b89c21ad Head: slot 7 | root f89ebf32 Justification at slot 2, finalization at slot 3 — chain progresses one slot per slot from there. --- bin/ethlambda/src/main.rs | 19 +++- crates/blockchain/src/lib.rs | 174 +++++++++++++++++++-------------- crates/blockchain/src/store.rs | 15 +++ 3 files changed, 136 insertions(+), 72 deletions(-) diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 89bb4974..3080e9c4 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -92,6 +92,18 @@ struct CliOptions { /// Directory for RocksDB storage #[arg(long, default_value = "./data")] data_dir: PathBuf, + + /// Produce a real cryptographic Type-2 SNARK on the block-building hot + /// path (proposer Type-1 wrap + `merge_many_type_1`). + /// + /// Off by default: each SNARK currently runs on the actor thread and + /// takes seconds, starving interval-1 attestation production. With the + /// flag off, blocks ship a metadata-only Type-2 envelope (empty SNARK + /// bytes); per-attestation crypto verification still runs at gossip + /// ingestion, and block-level verify falls back to its structural check. + /// Flip the flag on once the SNARK work is moved off the actor thread. + #[arg(long, default_value = "false")] + crypto_merge_t1_into_t2: bool, } #[tokio::main] @@ -208,7 +220,12 @@ async fn main() -> eyre::Result<()> { // and the API server (which exposes GET/POST admin endpoints). let aggregator = AggregatorController::new(options.is_aggregator); - let blockchain = BlockChain::spawn(store.clone(), validator_keys, aggregator.clone()); + let blockchain = BlockChain::spawn( + store.clone(), + validator_keys, + aggregator.clone(), + options.crypto_merge_t1_into_t2, + ); // Note: SwarmConfig.is_aggregator is intentionally a plain bool, not the // AggregatorController — subnet subscriptions are decided once here and diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index b2837cd0..a7e8bcea 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -62,6 +62,7 @@ impl BlockChain { store: Store, validator_keys: HashMap, aggregator: AggregatorController, + crypto_merge_t1_into_t2: bool, ) -> BlockChain { metrics::set_is_aggregator(aggregator.is_enabled()); metrics::set_node_sync_status(metrics::SyncStatus::Idle); @@ -76,6 +77,7 @@ impl BlockChain { pending_block_parents: HashMap::new(), current_aggregation: None, last_tick_instant: None, + crypto_merge_t1_into_t2, } .start(); let time_until_genesis = (SystemTime::UNIX_EPOCH + Duration::from_secs(genesis_time)) @@ -129,6 +131,16 @@ pub struct BlockChainServer { /// Last tick instant for measuring interval duration. last_tick_instant: Option, + + /// When `true`, `propose_block` produces a real Type-2 SNARK by wrapping + /// the proposer signature as a singleton Type-1 and calling + /// `merge_type_1s_into_type_2`. Each call currently takes several seconds + /// on the actor thread and blocks the message loop, so the default is + /// `false`: a metadata-only Type-2 envelope ships and the verifier falls + /// back to its structural-only path (per-attestation crypto still runs at + /// gossip ingestion). Flip to `true` once the SNARK work is moved off + /// the actor thread. + crypto_merge_t1_into_t2: bool, } impl BlockChainServer { @@ -339,10 +351,21 @@ impl BlockChainServer { return; }; - // Assemble SignedBlock: wrap the proposer's raw XMSS signature into a - // singleton Type-1 SNARK, then merge it with every attestation Type-1 - // into the block's single Type-2 proof (real lean-multisig devnet5 - // cryptography, replacing the structural-only stub used before). + // Assemble SignedBlock. We have two paths: + // + // * `crypto_merge_t1_into_t2`: wrap the proposer's raw XMSS into a + // singleton Type-1 SNARK and merge it with every attestation Type-1 + // into a real cryptographic Type-2. Correct but expensive — each + // proof currently takes seconds on the actor thread, starving + // interval-1 attestation production and blocking finality. + // * Stub path: produce a metadata-only Type-2 envelope (per-component + // `participants` + empty proof bytes). Block-level verify falls back + // to the structural check; per-attestation crypto verification still + // runs at gossip ingestion. + // + // Until the SNARK work is moved off the actor thread, the stub path is + // the default so the rest of the protocol (attestations, fork choice, + // justification, finality) can make progress. let head_state = self.store.head_state(); let validators = &head_state.validators; let Some(proposer_validator) = validators.get(validator_id as usize) else { @@ -350,87 +373,96 @@ impl BlockChainServer { metrics::inc_block_building_failures(); return; }; - let Ok(proposer_pubkey) = proposer_validator.get_proposal_pubkey().inspect_err( - |err| error!(%slot, %validator_id, %err, "Failed to decode proposer proposal pubkey"), - ) else { - metrics::inc_block_building_failures(); - return; - }; - let Ok(proposer_validator_signature) = - ValidatorSignature::from_bytes(&proposer_signature).inspect_err(|err| { - error!(%slot, %validator_id, %err, "Failed to decode proposer signature bytes") - }) - else { - metrics::inc_block_building_failures(); - return; - }; - let Ok(proposer_t1_bytes) = ethlambda_crypto::aggregate_signatures( - vec![proposer_pubkey.clone()], - vec![proposer_validator_signature], - &block_root, - slot as u32, - ) - .inspect_err( - |err| error!(%slot, %validator_id, %err, "Failed to wrap proposer signature as Type-1"), - ) else { - metrics::inc_block_building_failures(); - return; + let proposer_proof_bytes = if self.crypto_merge_t1_into_t2 { + let Ok(proposer_pubkey) = proposer_validator.get_proposal_pubkey().inspect_err( + |err| error!(%slot, %validator_id, %err, "Failed to decode proposer proposal pubkey"), + ) else { + metrics::inc_block_building_failures(); + return; + }; + let Ok(proposer_validator_signature) = + ValidatorSignature::from_bytes(&proposer_signature).inspect_err(|err| { + error!(%slot, %validator_id, %err, "Failed to decode proposer signature bytes") + }) + else { + metrics::inc_block_building_failures(); + return; + }; + let Ok(proposer_t1_bytes) = ethlambda_crypto::aggregate_signatures( + vec![proposer_pubkey.clone()], + vec![proposer_validator_signature], + &block_root, + slot as u32, + ) + .inspect_err(|err| { + error!(%slot, %validator_id, %err, "Failed to wrap proposer signature as Type-1") + }) else { + metrics::inc_block_building_failures(); + return; + }; + proposer_t1_bytes + } else { + ByteListMiB::default() }; - let proposer_t1 = TypeOneMultiSignature::for_proposer(validator_id, proposer_t1_bytes); - - // Resolve pubkeys per Type-1 component for merge_many_type_1. Attestation - // components use each participant's attestation_pubkey; the trailing - // proposer component uses the single proposal_pubkey. - let mut merge_inputs: Vec<(Vec, ByteListMiB)> = - Vec::with_capacity(type_one_proofs.len() + 1); - let mut resolve_failed = false; - for t1 in &type_one_proofs { - let mut pubkeys = Vec::new(); - for vid in t1.participant_indices() { - let Some(validator) = validators.get(vid as usize) else { - error!(%slot, %validator_id, vid, "Participant out of range while resolving pubkeys"); - resolve_failed = true; - break; - }; - match validator.get_attestation_pubkey() { - Ok(pk) => pubkeys.push(pk), - Err(err) => { - error!(%slot, %validator_id, vid, %err, "Failed to decode attestation pubkey"); + + let proposer_t1 = + TypeOneMultiSignature::for_proposer(validator_id, proposer_proof_bytes.clone()); + + let merged_proof_bytes = if self.crypto_merge_t1_into_t2 { + let proposer_pubkey = match proposer_validator.get_proposal_pubkey() { + Ok(pk) => pk, + Err(err) => { + error!(%slot, %validator_id, %err, "Failed to decode proposer proposal pubkey"); + metrics::inc_block_building_failures(); + return; + } + }; + let mut merge_inputs: Vec<(Vec, ByteListMiB)> = + Vec::with_capacity(type_one_proofs.len() + 1); + let mut resolve_failed = false; + for t1 in &type_one_proofs { + let mut pubkeys = Vec::new(); + for vid in t1.participant_indices() { + let Some(validator) = validators.get(vid as usize) else { + error!(%slot, %validator_id, vid, "Participant out of range while resolving pubkeys"); resolve_failed = true; break; + }; + match validator.get_attestation_pubkey() { + Ok(pk) => pubkeys.push(pk), + Err(err) => { + error!(%slot, %validator_id, vid, %err, "Failed to decode attestation pubkey"); + resolve_failed = true; + break; + } } } + if resolve_failed { + break; + } + merge_inputs.push((pubkeys, t1.proof.clone())); } if resolve_failed { - break; + metrics::inc_block_building_failures(); + return; } - merge_inputs.push((pubkeys, t1.proof.clone())); - } - if resolve_failed { - metrics::inc_block_building_failures(); - return; - } - merge_inputs.push((vec![proposer_pubkey], proposer_t1.proof.clone())); + merge_inputs.push((vec![proposer_pubkey], proposer_proof_bytes)); - let Ok(merged_proof_bytes) = ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) - .inspect_err( - |err| error!(%slot, %validator_id, %err, "Failed to merge Type-1s into Type-2"), - ) - else { - metrics::inc_block_building_failures(); - return; + match ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) { + Ok(bytes) => bytes, + Err(err) => { + error!(%slot, %validator_id, %err, "Failed to merge Type-1s into Type-2"); + metrics::inc_block_building_failures(); + return; + } + } + } else { + ByteListMiB::default() }; let mut all_proofs = type_one_proofs; all_proofs.push(proposer_t1); - // Strip the per-component Type-1 proof bytes when packing the Type-2 - // envelope. leanSpec PR #717 stores them inside `info[i].proof` to - // enable cheap split-without-SNARK, but with realistic - // lean-multisig devnet5 Type-1 sizes (~225 KiB) bundling N+1 copies - // overflows the 1 MiB `ByteListMiB` cap on the outer envelope. - // The merged proof bytes alone still verify the full binding; - // `split_type_2_by_message` is the SNARK-backed recovery path. let infos: Vec = all_proofs .into_iter() .map(|t1| TypeOneInfo { diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index dadf7dd5..047f62fb 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -1242,6 +1242,21 @@ pub fn verify_block_signatures( let structural_elapsed = total_start.elapsed(); + // Skip crypto when the merged proof carries no SNARK bytes (the stub path + // used while the actor-thread SNARK work is being moved off-thread — + // per-attestation crypto still runs at gossip ingestion). + if merged.proof.is_empty() { + let total_elapsed = total_start.elapsed(); + info!( + slot = block.slot, + attestation_count = attestations.len(), + ?structural_elapsed, + ?total_elapsed, + "Block Type-2 proof structural-only (empty SNARK bytes)" + ); + return Ok(()); + } + // Resolve pubkeys per Type-2 component for verify_type_2 and rederive the // expected (message, slot) bindings from the block body. Attestation // components use each participant's attestation_pubkey; the trailing From 2f34f9e411d5b0b6533fbf302ed18818558221c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 14 May 2026 16:42:22 -0300 Subject: [PATCH 05/13] review: address PR #370 greptile comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * lib.rs (#5): decode proposer proposal pubkey once and reuse it for the singleton Type-1 wrap and the merge inputs; was deserialized twice on the crypto hot path. * store.rs (#4): block / attestation slot u64→u32 overflow now maps to a dedicated `SlotOutOfRange(u64)` variant instead of being misreported as `ProposerSignatureVerificationFailed`. * store.rs (#1): explicitly document the security caveat of the empty- SNARK structural-only branch (proposer XMSS not crypto-verified) and note the two upstream mitigations (STF `process_block_header` rejects wrong proposer_index; per-attestation crypto still runs at gossip ingestion). The structural log line now flags it explicitly. * crypto/src/lib.rs (#2): outer Type-2 decompression failure in `split_type_2_by_message` returns a new `DeserializationFailed` variant instead of `ChildDeserializationFailed(0)`, which had implied a child at index 0 had failed. * types/src/block.rs (#3): annotate the intentional duplication of proof bytes between `TypeOneMultiSignature::info.proof` and the outer `proof` field — mirrors leanSpec PR #717's shape so a Type-1 embedded inside a Type-2's info[i] reads the same as a standalone Type-1. --- crates/blockchain/src/lib.rs | 34 +++++++++++++++++--------------- crates/blockchain/src/store.rs | 29 ++++++++++++++++++++++----- crates/common/crypto/src/lib.rs | 5 ++++- crates/common/types/src/block.rs | 9 +++++++++ 4 files changed, 55 insertions(+), 22 deletions(-) diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index a7e8bcea..f4b77af7 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -374,13 +374,23 @@ impl BlockChainServer { return; }; - let proposer_proof_bytes = if self.crypto_merge_t1_into_t2 { - let Ok(proposer_pubkey) = proposer_validator.get_proposal_pubkey().inspect_err( - |err| error!(%slot, %validator_id, %err, "Failed to decode proposer proposal pubkey"), - ) else { - metrics::inc_block_building_failures(); - return; - }; + // Decode the proposer's proposal pubkey once and reuse it both for the + // singleton Type-1 wrap and for the Type-2 merge inputs. Only needed on + // the crypto path; the stub path doesn't reference it. + let proposer_pubkey_opt = if self.crypto_merge_t1_into_t2 { + match proposer_validator.get_proposal_pubkey() { + Ok(pk) => Some(pk), + Err(err) => { + error!(%slot, %validator_id, %err, "Failed to decode proposer proposal pubkey"); + metrics::inc_block_building_failures(); + return; + } + } + } else { + None + }; + + let proposer_proof_bytes = if let Some(ref proposer_pubkey) = proposer_pubkey_opt { let Ok(proposer_validator_signature) = ValidatorSignature::from_bytes(&proposer_signature).inspect_err(|err| { error!(%slot, %validator_id, %err, "Failed to decode proposer signature bytes") @@ -409,15 +419,7 @@ impl BlockChainServer { let proposer_t1 = TypeOneMultiSignature::for_proposer(validator_id, proposer_proof_bytes.clone()); - let merged_proof_bytes = if self.crypto_merge_t1_into_t2 { - let proposer_pubkey = match proposer_validator.get_proposal_pubkey() { - Ok(pk) => pk, - Err(err) => { - error!(%slot, %validator_id, %err, "Failed to decode proposer proposal pubkey"); - metrics::inc_block_building_failures(); - return; - } - }; + let merged_proof_bytes = if let Some(proposer_pubkey) = proposer_pubkey_opt { let mut merge_inputs: Vec<(Vec, ByteListMiB)> = Vec::with_capacity(type_one_proofs.len() + 1); let mut resolve_failed = false; diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 047f62fb..67a86791 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -793,6 +793,9 @@ pub enum StoreError { #[error("Proposer signature verification failed")] ProposerSignatureVerificationFailed, + #[error("Block slot {0} exceeds u32 range")] + SlotOutOfRange(u64), + #[error("State transition failed: {0}")] StateTransitionFailed(#[from] ethlambda_state_transition::Error), @@ -1243,8 +1246,24 @@ pub fn verify_block_signatures( let structural_elapsed = total_start.elapsed(); // Skip crypto when the merged proof carries no SNARK bytes (the stub path - // used while the actor-thread SNARK work is being moved off-thread — - // per-attestation crypto still runs at gossip ingestion). + // used while the actor-thread SNARK work is being moved off-thread). + // + // SECURITY CAVEAT: in this branch the proposer's XMSS signature over the + // block root is NOT cryptographically verified — only the participants + // bitfield is checked against `block.proposer_index`. A peer that knows + // the elected proposer for a slot could submit a block claiming that + // proposer's authorship without holding the key. Two upstream mitigations + // still apply: + // * `ethlambda_state_transition::process_block_header` rejects any block + // whose `proposer_index` doesn't match the slot's elected proposer, so + // impersonation of a *different* validator still fails the state + // transition. + // * Per-attestation Type-1 signatures continue to verify cryptographically + // at gossip ingestion (`on_gossip_aggregated_attestation`), so the + // attestation body of the block is still bound to real signers. + // The stub path is a devnet-only convenience pending the SNARK off-thread + // refactor; production / interop deployments should run with + // `--crypto-merge-t1-into-t2`. if merged.proof.is_empty() { let total_elapsed = total_start.elapsed(); info!( @@ -1252,7 +1271,7 @@ pub fn verify_block_signatures( attestation_count = attestations.len(), ?structural_elapsed, ?total_elapsed, - "Block Type-2 proof structural-only (empty SNARK bytes)" + "Block Type-2 proof structural-only (empty SNARK bytes — proposer sig not crypto-verified)" ); return Ok(()); } @@ -1278,7 +1297,7 @@ pub fn verify_block_signatures( } pubkeys_per_component.push(pubkeys); let slot_u32 = u32::try_from(attestation.data.slot) - .map_err(|_| StoreError::ProposerSignatureVerificationFailed)?; + .map_err(|_| StoreError::SlotOutOfRange(attestation.data.slot))?; expected_bindings.push((attestation.data.hash_tree_root(), slot_u32)); } @@ -1290,7 +1309,7 @@ pub fn verify_block_signatures( .map_err(|_| StoreError::PubkeyDecodingFailed(block.proposer_index))?; pubkeys_per_component.push(vec![proposer_pubkey]); let block_slot_u32 = - u32::try_from(block.slot).map_err(|_| StoreError::ProposerSignatureVerificationFailed)?; + u32::try_from(block.slot).map_err(|_| StoreError::SlotOutOfRange(block.slot))?; expected_bindings.push((block_root, block_slot_u32)); let crypto_start = std::time::Instant::now(); diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index 31eec96b..260a20c0 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -47,6 +47,9 @@ pub enum AggregationError { #[error("child proof deserialization failed at index {0}")] ChildDeserializationFailed(usize), + #[error("outer proof deserialization failed")] + DeserializationFailed, + #[error("need at least 2 children for recursive aggregation, got {0}")] InsufficientChildren(usize), @@ -386,7 +389,7 @@ pub fn split_type_2_by_message( let type_2 = LMType2::decompress_without_pubkeys(proof_data.iter().as_slice(), pubkeys_per_info) - .ok_or(AggregationError::ChildDeserializationFailed(0))?; + .ok_or(AggregationError::DeserializationFailed)?; let matches: Vec = type_2 .info diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index 5de61f3a..8f1bb051 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -118,6 +118,15 @@ pub struct TypeTwoMultiSignature { impl TypeOneMultiSignature { /// Build a Type-1 proof carrying the given participant bitfield and the /// aggregated proof bytes. + /// + /// `info.proof` and the outer `proof` carry the same bytes. This mirrors + /// leanSpec PR #717's shape (`aggregate_type_1` returns + /// `TypeOneMultiSignature(info=TypeOneInfo(participants, proof=wire), + /// proof=wire)`) so that a Type-1 embedded inside a Type-2's `info[i]` + /// reads the same as a standalone Type-1. The cost is one extra heap copy + /// of ~225 KiB per Type-1 — acceptable in the gossip pipeline; if it + /// shows up in profiling, swap the inner `ByteListMiB` for an + /// `Arc` once SSZ derive supports it. pub fn new(participants: AggregationBits, proof_data: ByteListMiB) -> Self { Self { info: TypeOneInfo { From 70c7cdb15ae5c7e40fea32bc9d703cf2230545bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 15 May 2026 16:37:06 -0300 Subject: [PATCH 06/13] Revert "feat(blockchain): gate Type-2 SNARK behind --crypto-merge-t1-into-t2" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the gating flag and always run the real Type-2 SNARK in propose_block (proposer Type-1 wrap + merge_many_type_1) and the real verify_type_2 in verify_block_signatures. Keeps the review fixes from 2f34f9e: * slot u64→u32 overflow maps to `SlotOutOfRange(u64)`. * outer Type-2 decompress in `split_type_2_by_message` uses `DeserializationFailed`, not `ChildDeserializationFailed(0)`. * proposer proposal pubkey decoded once and reused. This re-introduces the actor-thread starvation issue documented in 3199e7d (each SNARK takes ~5 s, interval-1 attestations get queued past the slot boundary). The off-thread refactor is the proper fix; the gating flag isn't the shape we want to keep. --- bin/ethlambda/src/main.rs | 19 +--- crates/blockchain/src/lib.rs | 156 +++++++++++++-------------------- crates/blockchain/src/store.rs | 31 ------- 3 files changed, 61 insertions(+), 145 deletions(-) diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 3080e9c4..89bb4974 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -92,18 +92,6 @@ struct CliOptions { /// Directory for RocksDB storage #[arg(long, default_value = "./data")] data_dir: PathBuf, - - /// Produce a real cryptographic Type-2 SNARK on the block-building hot - /// path (proposer Type-1 wrap + `merge_many_type_1`). - /// - /// Off by default: each SNARK currently runs on the actor thread and - /// takes seconds, starving interval-1 attestation production. With the - /// flag off, blocks ship a metadata-only Type-2 envelope (empty SNARK - /// bytes); per-attestation crypto verification still runs at gossip - /// ingestion, and block-level verify falls back to its structural check. - /// Flip the flag on once the SNARK work is moved off the actor thread. - #[arg(long, default_value = "false")] - crypto_merge_t1_into_t2: bool, } #[tokio::main] @@ -220,12 +208,7 @@ async fn main() -> eyre::Result<()> { // and the API server (which exposes GET/POST admin endpoints). let aggregator = AggregatorController::new(options.is_aggregator); - let blockchain = BlockChain::spawn( - store.clone(), - validator_keys, - aggregator.clone(), - options.crypto_merge_t1_into_t2, - ); + let blockchain = BlockChain::spawn(store.clone(), validator_keys, aggregator.clone()); // Note: SwarmConfig.is_aggregator is intentionally a plain bool, not the // AggregatorController — subnet subscriptions are decided once here and diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index f4b77af7..baff62db 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -62,7 +62,6 @@ impl BlockChain { store: Store, validator_keys: HashMap, aggregator: AggregatorController, - crypto_merge_t1_into_t2: bool, ) -> BlockChain { metrics::set_is_aggregator(aggregator.is_enabled()); metrics::set_node_sync_status(metrics::SyncStatus::Idle); @@ -77,7 +76,6 @@ impl BlockChain { pending_block_parents: HashMap::new(), current_aggregation: None, last_tick_instant: None, - crypto_merge_t1_into_t2, } .start(); let time_until_genesis = (SystemTime::UNIX_EPOCH + Duration::from_secs(genesis_time)) @@ -131,16 +129,6 @@ pub struct BlockChainServer { /// Last tick instant for measuring interval duration. last_tick_instant: Option, - - /// When `true`, `propose_block` produces a real Type-2 SNARK by wrapping - /// the proposer signature as a singleton Type-1 and calling - /// `merge_type_1s_into_type_2`. Each call currently takes several seconds - /// on the actor thread and blocks the message loop, so the default is - /// `false`: a metadata-only Type-2 envelope ships and the verifier falls - /// back to its structural-only path (per-attestation crypto still runs at - /// gossip ingestion). Flip to `true` once the SNARK work is moved off - /// the actor thread. - crypto_merge_t1_into_t2: bool, } impl BlockChainServer { @@ -351,21 +339,13 @@ impl BlockChainServer { return; }; - // Assemble SignedBlock. We have two paths: - // - // * `crypto_merge_t1_into_t2`: wrap the proposer's raw XMSS into a - // singleton Type-1 SNARK and merge it with every attestation Type-1 - // into a real cryptographic Type-2. Correct but expensive — each - // proof currently takes seconds on the actor thread, starving - // interval-1 attestation production and blocking finality. - // * Stub path: produce a metadata-only Type-2 envelope (per-component - // `participants` + empty proof bytes). Block-level verify falls back - // to the structural check; per-attestation crypto verification still - // runs at gossip ingestion. + // Assemble SignedBlock: wrap the proposer's raw XMSS signature into a + // singleton Type-1 SNARK, then merge it with every attestation Type-1 + // into the block's single Type-2 proof. // - // Until the SNARK work is moved off the actor thread, the stub path is - // the default so the rest of the protocol (attestations, fork choice, - // justification, finality) can make progress. + // Both proofs run synchronously on the actor thread, so propose_block + // currently dominates the slot budget; see PR #370 for the off-thread + // refactor follow-up. let head_state = self.store.head_state(); let validators = &head_state.validators; let Some(proposer_validator) = validators.get(validator_id as usize) else { @@ -375,92 +355,76 @@ impl BlockChainServer { }; // Decode the proposer's proposal pubkey once and reuse it both for the - // singleton Type-1 wrap and for the Type-2 merge inputs. Only needed on - // the crypto path; the stub path doesn't reference it. - let proposer_pubkey_opt = if self.crypto_merge_t1_into_t2 { - match proposer_validator.get_proposal_pubkey() { - Ok(pk) => Some(pk), - Err(err) => { - error!(%slot, %validator_id, %err, "Failed to decode proposer proposal pubkey"); - metrics::inc_block_building_failures(); - return; - } - } - } else { - None + // singleton Type-1 wrap and for the Type-2 merge inputs. + let Ok(proposer_pubkey) = proposer_validator.get_proposal_pubkey().inspect_err( + |err| error!(%slot, %validator_id, %err, "Failed to decode proposer proposal pubkey"), + ) else { + metrics::inc_block_building_failures(); + return; }; - let proposer_proof_bytes = if let Some(ref proposer_pubkey) = proposer_pubkey_opt { - let Ok(proposer_validator_signature) = - ValidatorSignature::from_bytes(&proposer_signature).inspect_err(|err| { - error!(%slot, %validator_id, %err, "Failed to decode proposer signature bytes") - }) - else { - metrics::inc_block_building_failures(); - return; - }; - let Ok(proposer_t1_bytes) = ethlambda_crypto::aggregate_signatures( - vec![proposer_pubkey.clone()], - vec![proposer_validator_signature], - &block_root, - slot as u32, - ) - .inspect_err(|err| { - error!(%slot, %validator_id, %err, "Failed to wrap proposer signature as Type-1") - }) else { - metrics::inc_block_building_failures(); - return; - }; - proposer_t1_bytes - } else { - ByteListMiB::default() + let Ok(proposer_validator_signature) = + ValidatorSignature::from_bytes(&proposer_signature).inspect_err(|err| { + error!(%slot, %validator_id, %err, "Failed to decode proposer signature bytes") + }) + else { + metrics::inc_block_building_failures(); + return; + }; + let Ok(proposer_proof_bytes) = ethlambda_crypto::aggregate_signatures( + vec![proposer_pubkey.clone()], + vec![proposer_validator_signature], + &block_root, + slot as u32, + ) + .inspect_err( + |err| error!(%slot, %validator_id, %err, "Failed to wrap proposer signature as Type-1"), + ) else { + metrics::inc_block_building_failures(); + return; }; let proposer_t1 = TypeOneMultiSignature::for_proposer(validator_id, proposer_proof_bytes.clone()); - let merged_proof_bytes = if let Some(proposer_pubkey) = proposer_pubkey_opt { - let mut merge_inputs: Vec<(Vec, ByteListMiB)> = - Vec::with_capacity(type_one_proofs.len() + 1); - let mut resolve_failed = false; - for t1 in &type_one_proofs { - let mut pubkeys = Vec::new(); - for vid in t1.participant_indices() { - let Some(validator) = validators.get(vid as usize) else { - error!(%slot, %validator_id, vid, "Participant out of range while resolving pubkeys"); + let mut merge_inputs: Vec<(Vec, ByteListMiB)> = + Vec::with_capacity(type_one_proofs.len() + 1); + let mut resolve_failed = false; + for t1 in &type_one_proofs { + let mut pubkeys = Vec::new(); + for vid in t1.participant_indices() { + let Some(validator) = validators.get(vid as usize) else { + error!(%slot, %validator_id, vid, "Participant out of range while resolving pubkeys"); + resolve_failed = true; + break; + }; + match validator.get_attestation_pubkey() { + Ok(pk) => pubkeys.push(pk), + Err(err) => { + error!(%slot, %validator_id, vid, %err, "Failed to decode attestation pubkey"); resolve_failed = true; break; - }; - match validator.get_attestation_pubkey() { - Ok(pk) => pubkeys.push(pk), - Err(err) => { - error!(%slot, %validator_id, vid, %err, "Failed to decode attestation pubkey"); - resolve_failed = true; - break; - } } } - if resolve_failed { - break; - } - merge_inputs.push((pubkeys, t1.proof.clone())); } if resolve_failed { - metrics::inc_block_building_failures(); - return; + break; } - merge_inputs.push((vec![proposer_pubkey], proposer_proof_bytes)); + merge_inputs.push((pubkeys, t1.proof.clone())); + } + if resolve_failed { + metrics::inc_block_building_failures(); + return; + } + merge_inputs.push((vec![proposer_pubkey], proposer_proof_bytes)); - match ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) { - Ok(bytes) => bytes, - Err(err) => { - error!(%slot, %validator_id, %err, "Failed to merge Type-1s into Type-2"); - metrics::inc_block_building_failures(); - return; - } + let merged_proof_bytes = match ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) { + Ok(bytes) => bytes, + Err(err) => { + error!(%slot, %validator_id, %err, "Failed to merge Type-1s into Type-2"); + metrics::inc_block_building_failures(); + return; } - } else { - ByteListMiB::default() }; let mut all_proofs = type_one_proofs; diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 67a86791..38284e7d 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -1245,37 +1245,6 @@ pub fn verify_block_signatures( let structural_elapsed = total_start.elapsed(); - // Skip crypto when the merged proof carries no SNARK bytes (the stub path - // used while the actor-thread SNARK work is being moved off-thread). - // - // SECURITY CAVEAT: in this branch the proposer's XMSS signature over the - // block root is NOT cryptographically verified — only the participants - // bitfield is checked against `block.proposer_index`. A peer that knows - // the elected proposer for a slot could submit a block claiming that - // proposer's authorship without holding the key. Two upstream mitigations - // still apply: - // * `ethlambda_state_transition::process_block_header` rejects any block - // whose `proposer_index` doesn't match the slot's elected proposer, so - // impersonation of a *different* validator still fails the state - // transition. - // * Per-attestation Type-1 signatures continue to verify cryptographically - // at gossip ingestion (`on_gossip_aggregated_attestation`), so the - // attestation body of the block is still bound to real signers. - // The stub path is a devnet-only convenience pending the SNARK off-thread - // refactor; production / interop deployments should run with - // `--crypto-merge-t1-into-t2`. - if merged.proof.is_empty() { - let total_elapsed = total_start.elapsed(); - info!( - slot = block.slot, - attestation_count = attestations.len(), - ?structural_elapsed, - ?total_elapsed, - "Block Type-2 proof structural-only (empty SNARK bytes — proposer sig not crypto-verified)" - ); - return Ok(()); - } - // Resolve pubkeys per Type-2 component for verify_type_2 and rederive the // expected (message, slot) bindings from the block body. Attestation // components use each participant's attestation_pubkey; the trailing From 604ea4c738297910dfe2bb94205744caa5cef665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 20 May 2026 15:54:31 -0300 Subject: [PATCH 07/13] refactor(types): align block proof envelope with leanSpec PR #717 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the Rust-side SSZ wrapper around the merged block proof so the wire format matches leanSpec PR #717 exactly: - `SignedBlock.proof` now carries raw lean-multisig Type-2 `compress_without_pubkeys()` bytes directly. - `TypeOneMultiSignature` flattens to `{ participants, proof }`; the duplicated `TypeOneInfo` storage is gone. - `TypeOneInfo`, `TypeOneInfos`, and the Rust `TypeTwoMultiSignature` struct are removed — per-component participants are rederived at verify time from `block.body.attestations[i].aggregation_bits` and `block.proposer_index`. - `ByteListMiB` (1 MiB) is replaced with `ByteList512KiB` matching the spec container. `verify_block_signatures` stops decoding an SSZ envelope and feeds `signed_block.proof` straight into `verify_type_2_signature`; the structural participant cross-check moves into the lean-multisig verifier (it already binds pubkeys derived from the body). Block import builds the info-only known-pool entries from `aggregation_bits` instead of the discarded envelope. Legacy fixture conversions in `ethlambda-test-fixtures` that synthesised the envelope from per-attestation Type-1 bytes now emit an empty proof; the Hive `verify_signatures` driver path returns `LegacyFixtureNotConvertible` until fixtures ship the merged Type-2 blob. --- crates/blockchain/src/aggregation.rs | 6 +- crates/blockchain/src/lib.rs | 45 +--- crates/blockchain/src/store.rs | 205 ++++------------ .../blockchain/tests/forkchoice_spectests.rs | 2 +- crates/common/crypto/src/lib.rs | 36 +-- .../common/test-fixtures/src/fork_choice.rs | 45 +--- .../test-fixtures/src/verify_signatures.rs | 131 +++-------- crates/common/types/src/attestation.rs | 2 +- crates/common/types/src/block.rs | 219 ++++++------------ 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/net/rpc/src/test_driver.rs | 4 +- crates/storage/src/store.rs | 12 +- 14 files changed, 191 insertions(+), 530 deletions(-) diff --git a/crates/blockchain/src/aggregation.rs b/crates/blockchain/src/aggregation.rs index 78af91ec..7f6f7427 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::{ByteListMiB, TypeOneMultiSignature}, + block::{ByteList512KiB, TypeOneMultiSignature}, primitives::H256, signature::{ValidatorPublicKey, ValidatorSignature}, state::Validator, @@ -46,7 +46,7 @@ pub struct AggregationJob { pub(crate) slot: u64, /// Pre-resolved `(participant_pubkeys, proof_data)` pairs for children /// selected via greedy coverage. - pub(crate) children: Vec<(Vec, ByteListMiB)>, + pub(crate) children: Vec<(Vec, ByteList512KiB)>, pub(crate) accepted_child_ids: Vec, pub(crate) raw_pubkeys: Vec, pub(crate) raw_sigs: Vec, @@ -234,7 +234,7 @@ fn build_job( fn resolve_child_pubkeys( child_proofs: &[TypeOneMultiSignature], validators: &[Validator], -) -> (Vec<(Vec, ByteListMiB)>, Vec) { +) -> (Vec<(Vec, ByteList512KiB)>, Vec) { let mut children = Vec::with_capacity(child_proofs.len()); let mut accepted_child_ids: Vec = Vec::new(); diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 1800e102..40ee841a 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -8,14 +8,10 @@ use ethlambda_types::{ ShortRoot, aggregator::AggregatorController, attestation::{SignedAggregatedAttestation, SignedAttestation}, - block::{ - ByteListMiB, SignedBlock, TypeOneInfo, TypeOneInfos, TypeOneMultiSignature, - TypeTwoMultiSignature, - }, + block::{ByteList512KiB, SignedBlock}, primitives::{H256, HashTreeRoot as _}, signature::{ValidatorPublicKey, ValidatorSignature}, }; -use libssz::SszEncode as _; use crate::aggregation::{ AGGREGATION_DEADLINE, AggregateProduced, AggregationDeadline, AggregationDone, @@ -399,10 +395,7 @@ impl BlockChainServer { return; }; - let proposer_t1 = - TypeOneMultiSignature::for_proposer(validator_id, proposer_proof_bytes.clone()); - - let mut merge_inputs: Vec<(Vec, ByteListMiB)> = + let mut merge_inputs: Vec<(Vec, ByteList512KiB)> = Vec::with_capacity(type_one_proofs.len() + 1); let mut resolve_failed = false; for t1 in &type_one_proofs { @@ -433,7 +426,12 @@ impl BlockChainServer { } merge_inputs.push((vec![proposer_pubkey], proposer_proof_bytes)); - let merged_proof_bytes = match ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) { + // Merge yields raw lean-multisig Type-2 bytes; the block envelope + // carries them directly with no Rust-side SSZ wrapper (leanSpec PR + // #717 wire format). 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 proof_bytes = match ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) { Ok(bytes) => bytes, Err(err) => { error!(%slot, %validator_id, %err, "Failed to merge Type-1s into Type-2"); @@ -441,31 +439,8 @@ impl BlockChainServer { return; } }; - - let mut all_proofs = type_one_proofs; - all_proofs.push(proposer_t1); - let infos: Vec = all_proofs - .into_iter() - .map(|t1| TypeOneInfo { - participants: t1.info.participants, - proof: ByteListMiB::default(), - }) - .collect(); - let Ok(merged_infos) = TypeOneInfos::try_from(infos) else { - error!(%slot, %validator_id, "Too many Type-1 infos for Type-2 envelope"); - metrics::inc_block_building_failures(); - return; - }; - let merged_envelope = TypeTwoMultiSignature { - info: merged_infos, - proof: merged_proof_bytes, - }; - let Ok(proof_bytes) = ByteListMiB::try_from(merged_envelope.to_ssz()).inspect_err( - |err| error!(%slot, %validator_id, ?err, "Merged Type-2 envelope exceeds ByteListMiB"), - ) else { - metrics::inc_block_building_failures(); - return; - }; + // `type_one_proofs` is no longer needed past this point. + drop(type_one_proofs); let signed_block = SignedBlock { message: block, proof: proof_bytes, diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index b2adac4d..f162751e 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -12,16 +12,12 @@ use ethlambda_types::{ AggregatedAttestation, AggregationBits, Attestation, AttestationData, HashedAttestationData, SignedAggregatedAttestation, SignedAttestation, validator_indices, }, - block::{ - AggregatedAttestations, Block, BlockBody, ByteListMiB, SignedBlock, TypeOneMultiSignature, - TypeTwoMultiSignature, - }, + block::{AggregatedAttestations, Block, BlockBody, SignedBlock, TypeOneMultiSignature}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, signature::{ValidatorPublicKey, ValidatorSignature}, state::State, }; -use libssz::SszDecode as _; use tracing::{info, trace, warn}; use crate::{ @@ -386,7 +382,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.info.participants.count_ones(); + let num_participants = aggregated.proof.participants.count_ones(); let target_slot = aggregated.data.target.slot; let target_root = aggregated.data.target.root; let source_slot = aggregated.data.source.slot; @@ -515,34 +511,20 @@ fn on_block_core( // Process block body attestations and feed them into the payload buffer // so fork choice's LMD GHOST overlay can see block-only votes. // - // The merged Type-2 envelope carries info-only (participants) entries - // for each component to keep the on-wire envelope under the 1 MiB cap. - // Standalone Type-1 proof bytes are not recoverable from a block; + // Per-attestation participant bitfields come straight from + // `block.body.attestations[i].aggregation_bits`. Standalone Type-1 + // proof bytes are not recoverable from a block at import time; // downstream re-aggregation has to come from the gossip channel or be - // SNARK-split with `split_type_2_by_message`. Entries we insert here - // are info-only, used only for fork-choice vote bookkeeping. + // recovered by SNARK-splitting `signed_block.proof` via + // `split_type_2_by_message`. The entries inserted here are info-only, + // used only for fork-choice vote bookkeeping. let aggregated_attestations = &block.body.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(merged.info.iter().take(aggregated_attestations.len())) - { + for att in aggregated_attestations.iter() { let hashed = HashedAttestationData::new(att.data.clone()); - let type_one = TypeOneMultiSignature { - info: info.clone(), - proof: ByteListMiB::default(), - }; + let type_one = TypeOneMultiSignature::empty(att.aggregation_bits.clone()); known_entries.push((hashed, type_one)); // Count each participating validator as a valid attestation. let count = validator_indices(&att.aggregation_bits).count() as u64; @@ -788,12 +770,6 @@ pub enum StoreError { #[error("Validator signature verification failed")] SignatureVerificationFailed, - #[error("Proposer signature could not be decoded")] - ProposerSignatureDecodingFailed, - - #[error("Proposer signature verification failed")] - ProposerSignatureVerificationFailed, - #[error("Block slot {0} exceeds u32 range")] SlotOutOfRange(u64), @@ -841,17 +817,6 @@ pub enum StoreError { store_time: u64, }, - #[error( - "Attestations and signatures don't match in length: got {signatures} signatures and {attestations} attestations" - )] - AttestationSignatureMismatch { - signatures: usize, - attestations: usize, - }, - - #[error("Aggregated proof participants don't match attestation aggregation bits")] - ParticipantsMismatch, - #[error("Aggregated signature verification failed: {0}")] AggregateVerificationFailed(ethlambda_crypto::VerificationError), @@ -1047,7 +1012,7 @@ fn extend_proofs_greedily( .collect(); let att = AggregatedAttestation { - aggregation_bits: proof.info.participants.clone(), + aggregation_bits: proof.participants.clone(), data: att_data.clone(), }; @@ -1314,55 +1279,35 @@ pub fn verify_block_signatures( let block = &signed_block.message; let attestations = &block.body.attestations; - 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: merged.info.len(), - attestations: attestations.len(), - }); - } - let validators = &state.validators; let num_validators = validators.len() as u64; - // Per-attestation entries: participant bitfields must mirror the block - // body. The signed message and slot live on the body, not on the proof - // envelope (leanSpec PR #717), so they're rederived below for the crypto - // binding check rather than cross-checked here. - for (attestation, info) in attestations.iter().zip(merged.info.iter()) { - if attestation.aggregation_bits != info.participants { - return Err(StoreError::ParticipantsMismatch); - } + // Bounds-check participants before paying for the SNARK verifier. + // Per-component pubkeys are resolved from the block body itself; the + // wire proof carries no separate participant declaration to cross-check + // against (leanSpec PR #717). + for attestation in attestations.iter() { for vid in validator_indices(&attestation.aggregation_bits) { if vid >= num_validators { return Err(StoreError::InvalidValidatorIndex); } } } - - // Trailing proposer entry: single bit for `block.proposer_index`. - let proposer_info = &merged.info[attestations.len()]; - let block_root = block.hash_tree_root(); - let proposer_bits: Vec = validator_indices(&proposer_info.participants).collect(); - if proposer_bits != [block.proposer_index] { - return Err(StoreError::ProposerSignatureVerificationFailed); - } if block.proposer_index >= num_validators { return Err(StoreError::InvalidValidatorIndex); } + let block_root = block.hash_tree_root(); let structural_elapsed = total_start.elapsed(); // Resolve pubkeys per Type-2 component for verify_type_2 and rederive the // expected (message, slot) bindings from the block body. Attestation // components use each participant's attestation_pubkey; the trailing // proposer component uses the proposal_pubkey of `block.proposer_index`. + let expected_components = attestations.len() + 1; let mut pubkeys_per_component: Vec> = - Vec::with_capacity(merged.info.len()); - let mut expected_bindings: Vec<(H256, u32)> = Vec::with_capacity(merged.info.len()); + Vec::with_capacity(expected_components); + let mut expected_bindings: Vec<(H256, u32)> = Vec::with_capacity(expected_components); for attestation in attestations.iter() { let mut pubkeys = Vec::new(); @@ -1394,7 +1339,7 @@ pub fn verify_block_signatures( let crypto_start = std::time::Instant::now(); ethlambda_crypto::verify_type_2_signature( - &merged.proof, + &signed_block.proof, pubkeys_per_component, &expected_bindings, ) @@ -1465,88 +1410,22 @@ mod tests { use super::*; use ethlambda_types::{ attestation::{AggregatedAttestation, AggregationBits, AttestationData}, - block::{ - BlockBody, ByteListMiB, SignedBlock, TypeOneMultiSignature, TypeTwoMultiSignature, - }, + block::{BlockBody, ByteList512KiB, SignedBlock, TypeOneMultiSignature}, checkpoint::Checkpoint, 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. + /// Test helper: placeholder block proof bytes. + /// + /// In production the merged proof is the raw `compress_without_pubkeys()` + /// output of `merge_many_type_1`, which can only be built by the + /// lean-multisig prover. Tests that don't go through + /// `verify_block_signatures` use an empty blob. fn make_signed_block_proof( - proposer_index: u64, - attestation_proofs: Vec, - ) -> ByteListMiB { - let mut all = attestation_proofs; - all.push(TypeOneMultiSignature::for_proposer( - proposer_index, - ByteListMiB::default(), - )); - let merged = TypeTwoMultiSignature::from_type_1s(all); - ByteListMiB::try_from(merged.to_ssz()).expect("merged proof fits in ByteListMiB") - } - - #[test] - fn verify_signatures_rejects_participants_mismatch() { - // 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, - head: Checkpoint::default(), - target: Checkpoint::default(), - source: Checkpoint::default(), - }; - - // 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(); - - // ...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(); - proof_bits.set(2, true).unwrap(); - - let attestation = AggregatedAttestation { - aggregation_bits: attestation_bits, - data: attestation_data.clone(), - }; - let attestations = AggregatedAttestations::try_from(vec![attestation]).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); - let _ = block_root; // proof envelope no longer carries the block root - let proof = make_signed_block_proof(0, vec![mismatching_t1]); - - let signed_block = SignedBlock { - message: block, - proof, - }; - - let result = verify_block_signatures(&state, &signed_block); - assert!( - matches!(result, Err(StoreError::ParticipantsMismatch)), - "Expected ParticipantsMismatch, got: {result:?}" - ); + _proposer_index: u64, + _attestation_proofs: Vec, + ) -> ByteList512KiB { + ByteList512KiB::default() } /// Regression test for https://github.com/lambdaclass/ethlambda/issues/259 @@ -1566,11 +1445,9 @@ mod tests { use libssz_types::SszList; const MAX_PAYLOAD_SIZE: usize = 10 * 1024 * 1024; // 10 MiB (spec limit) - // The Type-2 envelope now embeds each per-component Type-1 proof in - // info[i].proof (leanSpec PR #717), so the per-component size has to - // budget for `MAX_ATTESTATIONS_DATA + 1` copies fitting in the 1 MiB - // ByteListMiB cap. 50 KiB per component is roughly what a real - // lean-multisig devnet5 Type-1 SNARK weighs in at. + // Each pool entry carries a fake Type-1 proof of this size. Realistic + // lean-multisig devnet5 Type-1 SNARKs weigh in around 200-400 KiB; we + // stay well under the 512 KiB cap so try_from never rejects. const PROOF_SIZE: usize = 50 * 1024; const NUM_VALIDATORS: usize = 50; const NUM_PAYLOAD_ENTRIES: usize = 50; @@ -1664,7 +1541,7 @@ mod tests { bits.set(validator_id, true).unwrap(); let proof_bytes: Vec = vec![0xAB; PROOF_SIZE]; - let proof_data = SszList::try_from(proof_bytes).expect("proof fits in ByteListMiB"); + let proof_data = SszList::try_from(proof_bytes).expect("proof fits in ByteList512KiB"); let proof = TypeOneMultiSignature::new(bits, proof_data); aggregated_payloads.insert(data_root, (att_data, vec![proof])); @@ -1689,8 +1566,12 @@ mod tests { "MAX_ATTESTATIONS_DATA should cap attestations: got {attestation_count}" ); - // Build the merged Type-2 proof exactly as `propose_block` would. - let proof = make_signed_block_proof(proposer_index, signatures); + // Substitute a worst-case-size proof to model what `propose_block` + // would attach. The actual SNARK can't be built without lean-multisig, + // but the size cap (`ByteList512KiB`) bounds the worst case. + let _ = signatures; + let proof = + ByteList512KiB::try_from(vec![0xAB; 512 * 1024]).expect("worst-case proof fits in cap"); let signed_block = SignedBlock { message: block, proof, @@ -2052,7 +1933,7 @@ mod tests { // Attestation bits mirror the proof's participants for each entry. for (att, proof) in &selected { - assert_eq!(att.aggregation_bits, proof.info.participants); + assert_eq!(att.aggregation_bits, proof.participants); assert_eq!(att.data, data); } } diff --git a/crates/blockchain/tests/forkchoice_spectests.rs b/crates/blockchain/tests/forkchoice_spectests.rs index fc38b532..4ace2692 100644 --- a/crates/blockchain/tests/forkchoice_spectests.rs +++ b/crates/blockchain/tests/forkchoice_spectests.rs @@ -144,7 +144,7 @@ fn run(path: &Path) -> datatest_stable::Result<()> { .expect("gossipAggregatedAttestation step missing proof"); let proof_bytes: Vec = proof_fixture.proof_data.into(); let proof_data = ByteList::try_from(proof_bytes) - .expect("aggregated proof data fits in ByteListMiB"); + .expect("aggregated proof data fits in ByteList512KiB"); let data: AttestationData = att_data.data.into(); let proof = TypeOneMultiSignature::new(proof_fixture.participants.into(), proof_data); diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index 260a20c0..cdf129d2 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -1,7 +1,7 @@ use std::sync::Once; use ethlambda_types::{ - block::ByteListMiB, + block::ByteList512KiB, primitives::H256, signature::{ValidatorPublicKey, ValidatorSignature}, }; @@ -116,7 +116,7 @@ fn into_lean_pubkeys(pubkeys: Vec) -> Vec { /// `TypeOneMultiSignature` by attaching the resolved validator pubkeys. fn decompress_type1( pubkeys: Vec, - proof_bytes: &ByteListMiB, + proof_bytes: &ByteList512KiB, index: usize, ) -> Result { let lean_pks = into_lean_pubkeys(pubkeys); @@ -124,16 +124,16 @@ fn decompress_type1( .ok_or(AggregationError::ChildDeserializationFailed(index)) } -fn compress_type1_to_byte_list(sig: &LMType1) -> Result { +fn compress_type1_to_byte_list(sig: &LMType1) -> Result { let serialized = sig.compress_without_pubkeys(); let len = serialized.len(); - ByteListMiB::try_from(serialized).map_err(|_| AggregationError::ProofTooBig(len)) + ByteList512KiB::try_from(serialized).map_err(|_| AggregationError::ProofTooBig(len)) } -fn compress_type2_to_byte_list(sig: &LMType2) -> Result { +fn compress_type2_to_byte_list(sig: &LMType2) -> Result { let serialized = sig.compress_without_pubkeys(); let len = serialized.len(); - ByteListMiB::try_from(serialized).map_err(|_| AggregationError::ProofTooBig(len)) + ByteList512KiB::try_from(serialized).map_err(|_| AggregationError::ProofTooBig(len)) } // ===================================================================== @@ -147,13 +147,13 @@ fn compress_type2_to_byte_list(sig: &LMType2) -> Result, signatures: Vec, message: &H256, slot: u32, -) -> Result { +) -> Result { if public_keys.len() != signatures.len() { return Err(AggregationError::CountMismatch( public_keys.len(), @@ -186,12 +186,12 @@ pub fn aggregate_signatures( /// Requires at least one raw signature OR at least 2 children. A lone child is /// already a valid Type-1; further aggregation is wasted work. pub fn aggregate_mixed( - children: Vec<(Vec, ByteListMiB)>, + children: Vec<(Vec, ByteList512KiB)>, raw_public_keys: Vec, raw_signatures: Vec, message: &H256, slot: u32, -) -> Result { +) -> Result { if raw_public_keys.len() != raw_signatures.len() { return Err(AggregationError::CountMismatch( raw_public_keys.len(), @@ -227,10 +227,10 @@ pub fn aggregate_mixed( /// All children must bind to the same `(message, slot)`. Used during block /// building to compact multiple proofs sharing an `AttestationData`. pub fn aggregate_proofs( - children: Vec<(Vec, ByteListMiB)>, + children: Vec<(Vec, ByteList512KiB)>, message: &H256, slot: u32, -) -> Result { +) -> Result { if children.len() < 2 { return Err(AggregationError::InsufficientChildren(children.len())); } @@ -256,7 +256,7 @@ pub fn aggregate_proofs( /// The verifier checks the bound `(message, slot)` matches what the caller /// expects, defending against proofs reused from other binding contexts. pub fn verify_aggregated_signature( - proof_data: &ByteListMiB, + proof_data: &ByteList512KiB, public_keys: Vec, message: &H256, slot: u32, @@ -293,8 +293,8 @@ pub fn verify_aggregated_signature( /// `TypeTwoMultiSignature`. A verifier decoding it back needs the per-component /// pubkey sets in the same order. pub fn merge_type_1s_into_type_2( - type_1s: Vec<(Vec, ByteListMiB)>, -) -> Result { + type_1s: Vec<(Vec, ByteList512KiB)>, +) -> Result { if type_1s.is_empty() { return Err(AggregationError::EmptyInput); } @@ -319,7 +319,7 @@ pub fn merge_type_1s_into_type_2( /// caller-supplied lists, checks they match what the proof binds, and then runs /// the inner SNARK verifier. pub fn verify_type_2_signature( - proof_data: &ByteListMiB, + proof_data: &ByteList512KiB, pubkeys_per_component: Vec>, expected_bindings: &[(H256, u32)], ) -> Result<(), VerificationError> { @@ -376,10 +376,10 @@ pub fn verify_type_2_signature( /// decompressed proof. Returns the `compress_without_pubkeys()` form of the /// resulting Type-1. pub fn split_type_2_by_message( - proof_data: &ByteListMiB, + proof_data: &ByteList512KiB, pubkeys_per_component: Vec>, message: &H256, -) -> Result { +) -> Result { ensure_prover_ready(); let pubkeys_per_info: Vec> = pubkeys_per_component diff --git a/crates/common/test-fixtures/src/fork_choice.rs b/crates/common/test-fixtures/src/fork_choice.rs index 2351a0da..a1c2d277 100644 --- a/crates/common/test-fixtures/src/fork_choice.rs +++ b/crates/common/test-fixtures/src/fork_choice.rs @@ -8,11 +8,8 @@ use crate::{ deser_xmss_hex, }; use ethlambda_types::attestation::XmssSignature; -use ethlambda_types::block::{ - ByteListMiB, MAX_ATTESTATIONS_DATA, SignedBlock, TypeOneMultiSignature, TypeTwoMultiSignature, -}; +use ethlambda_types::block::{ByteList512KiB, SignedBlock}; use ethlambda_types::primitives::H256; -use libssz::SszEncode as _; use serde::{Deserialize, Deserializer}; use std::collections::HashMap; use std::path::Path; @@ -151,43 +148,17 @@ impl BlockStepData { } } - /// Build a `SignedBlock` whose merged Type-2 proof is structurally correct - /// (one Type-1 info entry per block-body attestation plus a trailing - /// proposer entry) but carries empty proof bytes — the crypto layer is - /// never invoked by callers of this helper. + /// Build a `SignedBlock` with an empty proof blob. /// /// Used by callers that import the block via `on_block_without_verification` - /// (fork-choice spec-test runner and Hive test-driver), where - /// `process_new_block` still decodes the merged proof and asserts the info - /// list aligns with `attestations.len() + 1` before dispatching. - /// - /// Oversized-block tests (more than `MAX_ATTESTATIONS_DATA` attestations) - /// overflow `TypeOneInfos`'s SSZ-list cap, so we fall back to an empty - /// proof blob — `process_new_block` rejects with `TooManyAttestationData` - /// before the proof is ever decoded, so its contents don't matter for - /// those scenarios. + /// (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. pub fn to_blank_signed_block(&self) -> SignedBlock { - let block = self.to_block(); - let proof = if block.body.attestations.len() > MAX_ATTESTATIONS_DATA { - ByteListMiB::default() - } else { - let attestation_proofs: Vec = block - .body - .attestations - .iter() - .map(|att| TypeOneMultiSignature::empty(att.aggregation_bits.clone())) - .collect(); - let mut all = attestation_proofs; - all.push(TypeOneMultiSignature::for_proposer( - block.proposer_index, - ByteListMiB::default(), - )); - let merged = TypeTwoMultiSignature::from_type_1s(all); - ByteListMiB::try_from(merged.to_ssz()).expect("merged proof fits in ByteListMiB") - }; SignedBlock { - message: block, - proof, + message: self.to_block(), + proof: ByteList512KiB::default(), } } } diff --git a/crates/common/test-fixtures/src/verify_signatures.rs b/crates/common/test-fixtures/src/verify_signatures.rs index 96866083..8cffe8a4 100644 --- a/crates/common/test-fixtures/src/verify_signatures.rs +++ b/crates/common/test-fixtures/src/verify_signatures.rs @@ -5,11 +5,8 @@ //! same JSON shapes from the lean spec-assets simulator. use crate::{AggregationBits, Block, Container, TestInfo, TestState, deser_xmss_hex}; -use ethlambda_types::attestation::{AggregationBits as EthAggregationBits, XmssSignature}; -use ethlambda_types::block::{ - ByteListMiB, SignedBlock, TypeOneMultiSignature, TypeTwoMultiSignature, -}; -use libssz::SszEncode as _; +use ethlambda_types::attestation::XmssSignature; +use ethlambda_types::block::{ByteList512KiB, SignedBlock}; use serde::Deserialize; use std::collections::HashMap; use std::fmt; @@ -62,42 +59,22 @@ pub struct TestSignedBlock { pub signature: TestSignatureBundle, } -/// Lossy fixture-to-SignedBlock conversion: per-attestation proof bytes from -/// the fixture are dropped, leaving empty payloads. The merged Type-2 proof -/// preserves the per-attestation metadata (`message`, `slot`, `participants`) -/// and the proposer's XMSS signature so structural verification passes. -/// Adequate for callers that don't reach the leanVM aggregate verifier (e.g. -/// signature spec tests whose fixtures all set `expectException`). For real -/// signature verification use [`TestSignedBlock::try_into_signed_block_with_proofs`]. +/// Lossy fixture-to-SignedBlock conversion: every signature byte is dropped. +/// +/// Under the leanSpec PR #717 wire format the block envelope carries a single +/// opaque merged proof blob, not per-attestation Type-1s plus a proposer +/// signature. Existing devnet4-shaped fixtures predate that change and don't +/// ship a merged Type-2 blob — they ship per-attestation signature metadata +/// plus a raw XMSS proposer signature. There is no way to reconstruct the +/// merged Type-2 blob from those bytes without running the lean-multisig +/// prover, so this `From` impl yields an empty `proof` and the resulting +/// block fails real `verify_block_signatures`. Callers that don't reach the +/// verifier (fixtures with `expectException`) are unaffected. impl From for SignedBlock { fn from(value: TestSignedBlock) -> Self { - let block: ethlambda_types::block::Block = value.block.into(); - let proposer_proof = ByteListMiB::try_from(value.signature.proposer_signature.to_vec()) - .expect("XMSS signature fits in ByteListMiB"); - - let attestation_t1s: Vec = value - .signature - .attestation_signatures - .data - .into_iter() - .map(|att_sig| { - let participants: EthAggregationBits = att_sig.participants.into(); - TypeOneMultiSignature::empty(participants) - }) - .collect(); - - let mut all = attestation_t1s; - all.push(TypeOneMultiSignature::for_proposer( - block.proposer_index, - proposer_proof, - )); - let merged = TypeTwoMultiSignature::from_type_1s(all); - let proof = ByteListMiB::try_from(merged.to_ssz()) - .expect("merged Type-2 proof fits in ByteListMiB"); - SignedBlock { - message: block, - proof, + message: value.block.into(), + proof: ByteList512KiB::default(), } } } @@ -105,28 +82,18 @@ impl From for SignedBlock { /// Error returned by [`TestSignedBlock::try_into_signed_block_with_proofs`]. #[derive(Debug)] pub enum SignedBlockConvertError { - InvalidProofHex { index: usize, reason: String }, - ProofTooLarge { index: usize, len: usize }, - TooManyAttestationSignatures, + /// Devnet4-shaped fixtures cannot be converted to the leanSpec PR #717 + /// wire format without rebuilding the merged Type-2 proof through the + /// lean-multisig prover. Until fixtures ship the merged proof blob + /// directly, the Hive driver path returns this error. + LegacyFixtureNotConvertible, } impl fmt::Display for SignedBlockConvertError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidProofHex { index, reason } => { - write!( - f, - "attestation_signatures[{index}].proofData: invalid hex: {reason}" - ) - } - Self::ProofTooLarge { index, len } => { - write!( - f, - "attestation_signatures[{index}].proofData: {len} bytes exceeds ByteListMiB limit" - ) - } - Self::TooManyAttestationSignatures => { - f.write_str("attestation_signatures list exceeds AttestationSignatures limit") + Self::LegacyFixtureNotConvertible => { + f.write_str("fixture predates leanSpec PR #717 — merged Type-2 proof unavailable") } } } @@ -136,56 +103,12 @@ impl std::error::Error for SignedBlockConvertError {} impl TestSignedBlock { /// Materialize a `SignedBlock` that preserves the fixture-supplied - /// per-attestation proof bytes verbatim by folding every Type-1 plus the - /// proposer Type-1 into the block's merged Type-2 proof. The lossy - /// [`From`] impl above drops these bytes — use this one when the consumer - /// needs the original aggregate bytes (e.g. the Hive test-driver feeds - /// them through `verify_block_signatures`). + /// merged proof bytes. Until fixtures are updated to ship the merged + /// Type-2 proof blob directly (post leanSpec PR #717), this returns + /// [`SignedBlockConvertError::LegacyFixtureNotConvertible`]. pub fn try_into_signed_block_with_proofs(self) -> Result { - let block: ethlambda_types::block::Block = self.block.into(); - let proposer_proof = ByteListMiB::try_from(self.signature.proposer_signature.to_vec()) - .expect("XMSS signature fits in ByteListMiB"); - - let attestation_t1s: Vec = self - .signature - .attestation_signatures - .data - .into_iter() - .enumerate() - .map(|(index, att_sig)| { - let participants: EthAggregationBits = att_sig.participants.into(); - let raw = &att_sig.proof_data.data; - let stripped = raw.strip_prefix("0x").unwrap_or(raw); - let bytes = hex::decode(stripped).map_err(|err| { - SignedBlockConvertError::InvalidProofHex { - index, - reason: err.to_string(), - } - })?; - let len = bytes.len(); - let proof_data = ByteListMiB::try_from(bytes) - .map_err(|_| SignedBlockConvertError::ProofTooLarge { index, len })?; - Ok(TypeOneMultiSignature::new(participants, proof_data)) - }) - .collect::>()?; - - if attestation_t1s.len() >= 17 { - return Err(SignedBlockConvertError::TooManyAttestationSignatures); - } - - let mut all = attestation_t1s; - all.push(TypeOneMultiSignature::for_proposer( - block.proposer_index, - proposer_proof, - )); - let merged = TypeTwoMultiSignature::from_type_1s(all); - let proof = ByteListMiB::try_from(merged.to_ssz()) - .expect("merged Type-2 proof fits in ByteListMiB"); - - Ok(SignedBlock { - message: block, - proof, - }) + let _ = self; + Err(SignedBlockConvertError::LegacyFixtureNotConvertible) } } diff --git a/crates/common/types/src/attestation.rs b/crates/common/types/src/attestation.rs index 12d8f7b5..4b2206e3 100644 --- a/crates/common/types/src/attestation.rs +++ b/crates/common/types/src/attestation.rs @@ -150,7 +150,7 @@ pub fn bits_is_subset(a: &AggregationBits, b: &AggregationBits) -> bool { /// /// 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`. +/// `proof.participants`, and the raw aggregate bytes are in `proof.proof`. #[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] pub struct SignedAggregatedAttestation { pub data: AttestationData, diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index 71b1e074..ed5346c5 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -11,13 +11,14 @@ use crate::{ // Convenience trait for calling hash_tree_root() without a hasher argument use primitives::HashTreeRoot as _; -/// Envelope carrying a block and a single merged proof binding every signature -/// it depends on. +/// Envelope carrying a block and the 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)`. +/// `proof` holds the raw `compress_without_pubkeys()` form of the +/// lean-multisig `TypeTwoMultiSignature` covering, in order, every +/// per-attestation Type-1 plus a singleton Type-1 for the proposer's +/// signature over the block root. This matches leanSpec PR #717's wire +/// format exactly: no Rust-side SSZ wrapper around the lean-multisig bytes. /// ///
/// @@ -32,8 +33,8 @@ pub struct SignedBlock { /// The block being signed. pub message: Block, - /// SSZ-encoded merged proof for every signature this block depends on. - pub proof: ByteListMiB, + /// Raw lean-multisig Type-2 merged proof bytes (no Rust-side SSZ wrapper). + pub proof: ByteList512KiB, } // Manual Debug impl because the merged proof bytes are large and opaque. @@ -46,32 +47,24 @@ impl core::fmt::Debug for SignedBlock { } } -pub type ByteListMiB = ByteList<1_048_576>; +/// 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>; // ============================================================================ -// Type-1 / Type-2 multi-signature model +// Type-1 multi-signature // ============================================================================ // -// Wire format mirrors leanSpec PR #717: the proof envelope carries only what -// the verifier cannot rederive from the block body. `message` / `slot` / -// `bytecode_claim` are intentionally absent — the verifier reconstructs each -// component's binding from the block-body attestation it sits next to (plus -// the block root + slot for the proposer entry). - -/// Per-component metadata for a Type-1 multi-signer proof. -/// -/// Holds the participant bitfield and the per-component proof bytes in -/// compact no-pubkeys form. Inside a Type-2 envelope, `proof` is the standalone -/// Type-1 wire for this single component, enabling cheap disaggregation -/// without running a fresh SNARK. -#[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] -pub struct TypeOneInfo { - /// Bitfield indicating which validators contributed signatures. - pub participants: AggregationBits, - /// Standalone Type-1 proof bytes (`compress_without_pubkeys`) for this - /// component. Used by split-by-msg and by re-broadcast paths. - pub proof: ByteListMiB, -} +// Wire format mirrors leanSpec PR #717: `TypeOneMultiSignature` is a flat +// `{ participants, proof }` pair. The signed `message` and `slot` are NOT +// carried on the envelope — verifiers rederive each component's binding +// from the surrounding block body (attestation `data` + slot for body +// components, block root + slot for the proposer component). +// +// `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). /// Maximum number of distinct `AttestationData` entries permitted in a single /// block. Canonical home for the cap shared across `ethlambda-blockchain`, @@ -80,60 +73,32 @@ pub struct TypeOneInfo { /// 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`. -pub type TypeOneInfos = SszList; - /// A Type-1 single-message proof aggregating signatures from many validators. /// -/// The outer `proof` field is the canonical aggregated proof bytes; `info.proof` -/// holds the same bytes (kept aligned so a Type-1 embedded inside a Type-2's -/// info list reads identically standalone). `message` and `slot` live on the -/// caller-side block body, not on this envelope. -#[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] -pub struct TypeOneMultiSignature { - /// Per-component participant bitfield plus the standalone proof bytes. - pub info: TypeOneInfo, - /// Aggregated proof bytes in compact no-pubkeys representation. - pub proof: ByteListMiB, -} - -/// A Type-2 merged proof covering many distinct messages. +/// Used: +/// - as a gossip-level `SignedAggregatedAttestation.proof`, +/// - as an in-memory entry in the aggregated payload pool, +/// - as one of the components fed into `merge_type_1s_into_type_2` when +/// building a block proof. /// -/// `signed_block.proof` carries the SSZ-encoded form of this container. The -/// `info` list enumerates per-component (participants, standalone Type-1 -/// proof bytes); messages and slots are reconstructed at verify time from the -/// block body. +/// `participants` and `proof` are independent fields: the proof bytes are +/// the lean-multisig `compress_without_pubkeys()` form; `participants` is +/// the bitfield identifying which validators are bound by the proof. The +/// verifier resolves pubkeys from `participants` at verify time. #[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] -pub struct TypeTwoMultiSignature { - /// Per-component metadata, one entry per merged Type-1 proof. - pub info: TypeOneInfos, - /// Merged proof bytes in compact no-pubkeys representation. - pub proof: ByteListMiB, +pub struct TypeOneMultiSignature { + /// Bitfield identifying validators bound by this proof. + pub participants: AggregationBits, + /// Aggregated proof bytes in lean-multisig compact (no-pubkeys) form. + pub proof: ByteList512KiB, } impl TypeOneMultiSignature { - /// Build a Type-1 proof carrying the given participant bitfield and the - /// aggregated proof bytes. - /// - /// `info.proof` and the outer `proof` carry the same bytes. This mirrors - /// leanSpec PR #717's shape (`aggregate_type_1` returns - /// `TypeOneMultiSignature(info=TypeOneInfo(participants, proof=wire), - /// proof=wire)`) so that a Type-1 embedded inside a Type-2's `info[i]` - /// reads the same as a standalone Type-1. The cost is one extra heap copy - /// of ~225 KiB per Type-1 — acceptable in the gossip pipeline; if it - /// shows up in profiling, swap the inner `ByteListMiB` for an - /// `Arc` once SSZ derive supports it. - pub fn new(participants: AggregationBits, proof_data: ByteListMiB) -> Self { + /// Build a Type-1 proof carrying the given participants and proof bytes. + pub fn new(participants: AggregationBits, proof: ByteList512KiB) -> Self { Self { - info: TypeOneInfo { - participants, - proof: proof_data.clone(), - }, - proof: proof_data, + participants, + proof, } } @@ -150,7 +115,7 @@ impl TypeOneMultiSignature { /// The bytes must be a real aggregated Type-1 over the proposer's XMSS /// signature (e.g. from `ethlambda_crypto::aggregate_signatures`), not /// raw XMSS bytes — `verify_type_2` rejects raw-XMSS placeholders. - pub fn for_proposer(proposer_index: u64, proposer_proof_bytes: ByteListMiB) -> Self { + pub fn for_proposer(proposer_index: u64, proposer_proof_bytes: ByteList512KiB) -> Self { let mut participants = AggregationBits::with_length(proposer_index as usize + 1) .expect("validator index fits"); participants @@ -161,28 +126,7 @@ impl TypeOneMultiSignature { /// Returns the validator indices that are set in the participants bitfield. pub fn participant_indices(&self) -> impl Iterator + '_ { - validator_indices(&self.info.participants) - } -} - -impl TypeTwoMultiSignature { - /// Build a Type-2 envelope from a list of Type-1 components with EMPTY - /// merged proof bytes. Useful for tests that exercise the structural - /// fast-fail leg of `verify_block_signatures` (participants mismatch, - /// missing entries, …) without paying the lean-multisig SNARK cost. - /// - /// Production block production uses - /// [`ethlambda_crypto::merge_type_1s_into_type_2`] to produce a real - /// cryptographic Type-2 proof; do not use this helper for any path that - /// actually verifies the merged proof. - pub fn from_type_1s(type_1s: Vec) -> Self { - 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"); - Self { - info, - proof: ByteListMiB::default(), - } + validator_indices(&self.participants) } } @@ -299,72 +243,39 @@ mod tests { b } - fn sample_type_one_info() -> TypeOneInfo { - TypeOneInfo { - participants: sample_bits(8, &[0, 3, 7]), - proof: ByteListMiB::try_from((0..32u8).collect::>()).unwrap(), - } - } - - #[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.participants.as_bytes(), - info.participants.as_bytes() - ); - assert_eq!(decoded.proof.to_vec(), info.proof.to_vec()); - } - #[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(), + participants: sample_bits(8, &[0, 3, 7]), + proof: ByteList512KiB::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.participants.as_bytes(), - sig.info.participants.as_bytes() - ); + assert_eq!(decoded.participants.as_bytes(), sig.participants.as_bytes()); } #[test] - fn type_two_multi_signature_ssz_round_trip() { - let infos: Vec = (0..3) - .map(|i| TypeOneInfo { - participants: sample_bits(8, &[i, i + 1]), - proof: ByteListMiB::try_from(vec![i as u8; 16]).unwrap(), - }) - .collect(); - let merged_bytes: Vec = (0..128).map(|i| (i % 256) as u8).collect(); - let sig = TypeTwoMultiSignature { - info: TypeOneInfos::try_from(infos.clone()).unwrap(), - proof: ByteListMiB::try_from(merged_bytes.clone()).unwrap(), + fn signed_block_ssz_round_trip_empty_proof() { + let block = Block { + slot: 7, + proposer_index: 3, + parent_root: H256::ZERO, + state_root: H256::ZERO, + body: BlockBody::default(), }; - 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); - for (got, want) in decoded.info.iter().zip(infos.iter()) { - assert_eq!(got.participants.as_bytes(), want.participants.as_bytes()); - assert_eq!(got.proof.to_vec(), want.proof.to_vec()); - } - } - - #[test] - fn type_one_infos_respects_limit() { - let too_many: Vec = (0..18) - .map(|_| TypeOneInfo { - participants: sample_bits(1, &[0]), - proof: ByteListMiB::default(), - }) - .collect(); - assert!(TypeOneInfos::try_from(too_many).is_err()); + let signed = SignedBlock { + message: block, + proof: ByteList512KiB::default(), + }; + let bytes = signed.to_ssz(); + let decoded = SignedBlock::from_ssz_bytes(&bytes).expect("decode"); + assert_eq!(decoded.proof.len(), 0); + assert_eq!(decoded.message.slot, signed.message.slot); + assert_eq!( + decoded.message.proposer_index, + signed.message.proposer_index + ); } } diff --git a/crates/common/types/tests/ssz_types.rs b/crates/common/types/tests/ssz_types.rs index d5395b29..c825eaa7 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: ByteListMiB` field. The pinned +// `SignedBlock` now carries a single `proof: ByteList512KiB` 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 b8ead618..75fd1068 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, ByteListMiB}, + block::{Block, BlockBody, ByteList512KiB}, state::State, }; use std::sync::Arc; @@ -414,7 +414,7 @@ mod tests { state_root: H256::ZERO, body: BlockBody::default(), }, - proof: ByteListMiB::default(), + proof: ByteList512KiB::default(), } } diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index e95bcb6d..67977b83 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, ByteListMiB, SignedBlock}, + block::{Block, BlockBody, ByteList512KiB, 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: ByteListMiB::default(), + proof: ByteList512KiB::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::{ByteListMiB, SignedBlock}; + use ethlambda_types::block::{ByteList512KiB, 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: ByteListMiB::default(), + proof: ByteList512KiB::default(), }; let expected_ssz = expected.to_ssz(); diff --git a/crates/net/rpc/src/test_driver.rs b/crates/net/rpc/src/test_driver.rs index 2b7cc33e..e62de98b 100644 --- a/crates/net/rpc/src/test_driver.rs +++ b/crates/net/rpc/src/test_driver.rs @@ -42,7 +42,7 @@ use ethlambda_types::{ attestation::{ AggregationBits as EthAggregationBits, SignedAggregatedAttestation, SignedAttestation, }, - block::{Block, ByteListMiB, TypeOneMultiSignature}, + block::{Block, ByteList512KiB, TypeOneMultiSignature}, checkpoint::Checkpoint, primitives::H256, state::{State, anchor_pair_is_consistent}, @@ -387,7 +387,7 @@ fn apply_step(store: &mut Store, step: ForkChoiceStep) -> Result<(), String> { .ok_or_else(|| "gossipAggregatedAttestation step missing proof".to_string())?; let participants: EthAggregationBits = proof.participants.into(); let proof_bytes: Vec = proof.proof_data.into(); - let proof_data = ByteListMiB::try_from(proof_bytes) + let proof_data = ByteList512KiB::try_from(proof_bytes) .map_err(|err| format!("aggregated proof data too large: {err:?}"))?; let data: ethlambda_types::attestation::AttestationData = att.data.into(); let aggregated = SignedAggregatedAttestation { diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 385bfb86..e1d6a380 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -5,7 +5,7 @@ use crate::api::{StorageBackend, StorageWriteBatch, Table}; use ethlambda_types::{ attestation::{AttestationData, HashedAttestationData, bits_is_subset}, - block::{Block, BlockBody, BlockHeader, ByteListMiB, SignedBlock, TypeOneMultiSignature}, + block::{Block, BlockBody, BlockHeader, ByteList512KiB, SignedBlock, TypeOneMultiSignature}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, signature::ValidatorSignature, @@ -154,12 +154,12 @@ impl PayloadBuffer { 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.info.participants, &p.info.participants) { + if bits_is_subset(&proof.participants, &p.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.info.participants, &proof.info.participants) { + if bits_is_subset(&p.participants, &proof.participants) { to_remove.push(i); } } @@ -1015,12 +1015,12 @@ impl Store { let proof = match view.get(Table::BlockSignatures, &key).expect("get") { Some(proof_bytes) => { - ByteListMiB::from_ssz_bytes(&proof_bytes).expect("valid block proof") + ByteList512KiB::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 => ByteListMiB::default(), + None if header.slot == 0 => ByteList512KiB::default(), None => return None, }; @@ -2345,7 +2345,7 @@ mod tests { .expect("genesis block must be retrievable with synthetic proof"); assert_eq!(signed.message.slot, 0); - assert_eq!(signed.proof, ByteListMiB::default()); + assert_eq!(signed.proof, ByteList512KiB::default()); } /// The synthesis branch must be confined to the slot-0 anchor: a From cc3df594c8ae0ec6411e829c63e18ce22385f174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 20 May 2026 16:01:41 -0300 Subject: [PATCH 08/13] feat(blockchain): reaggregate per-attestation Type-1s from imported blocks Ports leanSpec PR #717 `SyncService._deconstruct_block_into_store` to ethlambda. After a successful block import, the actor SNARK-splits the block's merged Type-2 proof into per-attestation Type-1s, merges each with locally-held partial proofs covering the same AttestationData, and writes the combined proof into the new-payload pool. When this node acts as an aggregator, the recovered aggregates are republished on gossip so peers that only saw a partial vote can converge on the full set. Without this path, a catching-up validator that imports an attestation through a block never republishes it on gossip, and the rest of the network never converges on the full participant set. Cost is bounded by: - only running when the chain is in sync (synced flag returned from `process_block`); - skipping attestations whose target is at or behind the store's justified checkpoint; - skipping attestations whose participants are already a subset of the local union for that data; - capping splits per block at `MAX_REAGGREGATIONS_PER_BLOCK = 4`, prioritising candidates with the most uncovered validators. Each `split_type_2_by_message` runs a fresh SNARK. The current implementation runs synchronously on the actor thread; an off-thread worker pattern (mirroring `aggregation::run_aggregation_worker`) is a natural follow-up if profiling shows it bleeding into the slot budget. Unit tests cover the candidate-selection rules without paying the SNARK cost (target-slot gate, participant subset gate, hard cap, priority ordering). --- crates/blockchain/src/lib.rs | 54 +++- crates/blockchain/src/reaggregate.rs | 374 +++++++++++++++++++++++++++ 2 files changed, 423 insertions(+), 5 deletions(-) create mode 100644 crates/blockchain/src/reaggregate.rs diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 40ee841a..c76c2a38 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -31,6 +31,7 @@ pub mod aggregation; pub(crate) mod fork_choice_tree; pub mod key_manager; pub mod metrics; +pub mod reaggregate; pub mod store; pub struct BlockChain { @@ -465,7 +466,9 @@ impl BlockChainServer { info!(%slot, %validator_id, "Published block"); } - fn process_block(&mut self, signed_block: SignedBlock) -> Result<(), StoreError> { + /// Run block import, refresh metrics, and report whether the node is in + /// sync with the wall-clock slot after the import. + fn process_block(&mut self, signed_block: SignedBlock) -> Result { store::on_block(&mut self.store, signed_block)?; let head_slot = self.store.head_slot(); metrics::update_head_slot(head_slot); @@ -475,7 +478,8 @@ impl BlockChainServer { // Update sync status based on head slot vs wall clock slot let current_slot = self.store.time() / INTERVALS_PER_SLOT; - let status = if head_slot >= current_slot { + let synced = head_slot >= current_slot; + let status = if synced { metrics::SyncStatus::Synced } else { metrics::SyncStatus::Syncing @@ -485,7 +489,7 @@ impl BlockChainServer { for table in ALL_TABLES { metrics::update_table_bytes(table.name(), self.store.estimate_table_bytes(table)); } - Ok(()) + Ok(synced) } /// Process a newly received block. @@ -603,9 +607,12 @@ impl BlockChainServer { return; } - // Parent exists, proceed with processing + // Parent exists, proceed with processing. Clone the block so we + // can run post-import reaggregation against its merged proof — + // `process_block` consumes the original for the storage layer. + let block_for_reaggregate = signed_block.clone(); match self.process_block(signed_block) { - Ok(_) => { + Ok(synced) => { info!( %slot, proposer, @@ -614,6 +621,17 @@ impl BlockChainServer { "Block imported successfully" ); + // Recover per-attestation Type-1 proofs from the block's + // merged Type-2 and fold them into the local pool. Only + // run when the chain is in sync — backfilling nodes must + // not spam gossip with rederived aggregates. Non-validator + // nodes still benefit from the store update because the + // recovered proofs feed fork choice on the next acceptance + // tick. + if synced { + self.run_reaggregate_from_block(&block_for_reaggregate); + } + // Enqueue any pending blocks that were waiting for this parent self.collect_pending_children(block_root, queue); } @@ -630,6 +648,32 @@ impl BlockChainServer { } } + /// Run the post-import reaggregation pass and publish the resulting + /// aggregates when this node is in the aggregator role. + fn run_reaggregate_from_block(&mut self, signed_block: &SignedBlock) { + let aggregates = reaggregate::reaggregate_from_block(&mut self.store, signed_block); + if aggregates.is_empty() { + return; + } + let count = aggregates.len(); + let is_aggregator = self.aggregator.is_enabled(); + info!( + count, + is_aggregator, "Reaggregated block-borne attestations" + ); + if !is_aggregator { + return; + } + let Some(ref p2p) = self.p2p else { + return; + }; + for aggregate in aggregates { + let _ = p2p + .publish_aggregated_attestation(aggregate) + .inspect_err(|err| warn!(%err, "Failed to publish reaggregated attestation")); + } + } + fn request_missing_block(&mut self, block_root: H256) { // Send request to P2P layer (deduplication handled by P2P module) if let Some(ref p2p) = self.p2p { diff --git a/crates/blockchain/src/reaggregate.rs b/crates/blockchain/src/reaggregate.rs new file mode 100644 index 00000000..53c887ae --- /dev/null +++ b/crates/blockchain/src/reaggregate.rs @@ -0,0 +1,374 @@ +//! Reaggregate-from-block: recover per-attestation Type-1 proofs from a +//! freshly imported block's merged Type-2 proof and fold them into the local +//! aggregated-payload pool. +//! +//! Mirrors leanSpec PR #717 `SyncService._deconstruct_block_into_store`. +//! Required for catching-up nodes (and aggregators) to surface block-borne +//! votes to the rest of the network — without this, a validator that only +//! observed an attestation through a block can't republish it on gossip. +//! +//! ## Cost +//! +//! Each `split_type_2_by_message` runs a fresh SNARK. We bound the worst +//! case by: +//! +//! 1. Only deconstructing when the chain is in sync — backfilling nodes +//! must not flood gossip with rederived aggregates. +//! 2. Skipping attestations whose target is at or behind the store's +//! justified checkpoint — they carry no fork-choice value. +//! 3. Skipping attestations whose participants are already a subset of the +//! local union for that data — nothing to recover. +//! 4. Capping the number of splits per imported block at +//! [`MAX_REAGGREGATIONS_PER_BLOCK`] so an attacker-shaped block cannot +//! blow past the slot budget. + +use std::collections::HashSet; + +use ethlambda_storage::Store; +use ethlambda_types::{ + attestation::{ + AggregatedAttestation, HashedAttestationData, SignedAggregatedAttestation, + validator_indices, + }, + block::{SignedBlock, TypeOneMultiSignature}, + primitives::{H256, HashTreeRoot as _}, + signature::ValidatorPublicKey, +}; +use tracing::{debug, warn}; + +/// Maximum number of attestations whose Type-1 we will SNARK-split out of +/// any single imported block. Each split runs a fresh recursive SNARK +/// (~hundreds of ms) so the cap keeps block-import latency predictable. +pub const MAX_REAGGREGATIONS_PER_BLOCK: usize = 4; + +/// Recover per-attestation Type-1 proofs from a freshly imported block. +/// +/// Returns the combined aggregates that gained new validator coverage; the +/// caller publishes them on gossip when this node acts as an aggregator. +/// Always updates the store regardless of role so non-aggregator nodes +/// still get the fork-choice weight from block-imported votes. +pub fn reaggregate_from_block( + store: &mut Store, + signed_block: &SignedBlock, +) -> Vec { + let block = &signed_block.message; + let attestations: Vec = + block.body.attestations.iter().cloned().collect(); + if attestations.is_empty() { + return Vec::new(); + } + + // The Type-2 proof was built against the parent state's validator set. + // Without it we cannot resolve the pubkey layout the SNARK was bound to. + let Some(parent_state) = store.get_state(&block.parent_root) else { + debug!( + block_root = %ethlambda_types::ShortRoot(&block.hash_tree_root().0), + "Skipping reaggregation: parent state missing" + ); + return Vec::new(); + }; + let validators = &parent_state.validators; + let num_validators = validators.len() as u64; + + // Per-component pubkeys: one entry per body attestation in order, then + // the proposer entry. Layout is invariant per block, so it's resolved + // once and reused for every split call below. + let mut pubkeys_per_component: Vec> = + Vec::with_capacity(attestations.len() + 1); + for att in &attestations { + let mut pubkeys = Vec::new(); + for vid in validator_indices(&att.aggregation_bits) { + if vid >= num_validators { + warn!(vid, "Reaggregation aborted: participant out of range"); + return Vec::new(); + } + let Ok(pk) = validators[vid as usize].get_attestation_pubkey() else { + warn!(vid, "Reaggregation aborted: bad attestation pubkey"); + return Vec::new(); + }; + pubkeys.push(pk); + } + pubkeys_per_component.push(pubkeys); + } + if block.proposer_index >= num_validators { + return Vec::new(); + } + let Ok(proposer_pubkey) = validators[block.proposer_index as usize].get_proposal_pubkey() + else { + return Vec::new(); + }; + pubkeys_per_component.push(vec![proposer_pubkey]); + + let candidates = select_candidates(store, &attestations); + if candidates.is_empty() { + return Vec::new(); + } + + // Run the splits and merges. A failure on one attestation is logged + // and skipped — partial progress still surfaces useful aggregates. + let mut aggregates: Vec = Vec::with_capacity(candidates.len()); + let mut store_inserts: Vec<(HashedAttestationData, TypeOneMultiSignature)> = + Vec::with_capacity(candidates.len()); + + for candidate in candidates { + let att = &attestations[candidate.idx]; + let data_root = candidate.data_root; + let slot_u32: u32 = match att.data.slot.try_into() { + Ok(s) => s, + Err(_) => continue, + }; + + // Step 1: SNARK-split this attestation's component out of the + // block's merged Type-2 proof. + let split_bytes = match ethlambda_crypto::split_type_2_by_message( + &signed_block.proof, + pubkeys_per_component.clone(), + &data_root, + ) { + Ok(bytes) => bytes, + Err(err) => { + debug!(%err, data_root = %ethlambda_types::ShortRoot(&data_root.0), + "Reaggregation split failed"); + continue; + } + }; + let block_t1 = + TypeOneMultiSignature::new(att.aggregation_bits.clone(), split_bytes.clone()); + + // Step 2: merge the split with local partials covering the same + // AttestationData so the combined proof binds every known signer. + // A child-only merge needs ≥ 2 children; if we only have the + // block proof, use it as-is. + let combined = if candidate.local_partials.is_empty() { + block_t1 + } else { + let mut children: Vec<(Vec, _)> = + Vec::with_capacity(1 + candidate.local_partials.len()); + + // First child: the split-from-block proof, paired with the + // pubkeys derived from the block attestation's participant set. + let block_att_pubkeys = pubkeys_per_component[candidate.idx].clone(); + children.push((block_att_pubkeys, split_bytes)); + + // Remaining children: local partial Type-1s for the same data. + let mut bad = false; + for partial in &candidate.local_partials { + let mut pubkeys = Vec::with_capacity(partial.participants.count_ones()); + for vid in partial.participant_indices() { + if vid >= num_validators { + bad = true; + break; + } + match validators[vid as usize].get_attestation_pubkey() { + Ok(pk) => pubkeys.push(pk), + Err(_) => { + bad = true; + break; + } + } + } + if bad { + break; + } + children.push((pubkeys, partial.proof.clone())); + } + if bad { + continue; + } + + let merged_bytes = + match ethlambda_crypto::aggregate_proofs(children, &data_root, slot_u32) { + Ok(bytes) => bytes, + Err(err) => { + debug!(%err, data_root = %ethlambda_types::ShortRoot(&data_root.0), + "Reaggregation merge failed"); + continue; + } + }; + // Union the block participants with every local partial's + // participants — the merged proof binds them all. + let mut union_indices: HashSet = + validator_indices(&att.aggregation_bits).collect(); + for partial in &candidate.local_partials { + union_indices.extend(partial.participant_indices()); + } + let max_vid = union_indices.iter().copied().max().unwrap_or(0); + let mut union_bits = + ethlambda_types::attestation::AggregationBits::with_length(max_vid as usize + 1) + .expect("union bitfield length within capacity"); + for vid in &union_indices { + union_bits + .set(*vid as usize, true) + .expect("vid within union bitfield length"); + } + TypeOneMultiSignature::new(union_bits, merged_bytes) + }; + + let hashed = HashedAttestationData::new(att.data.clone()); + store_inserts.push((hashed.clone(), combined.clone())); + aggregates.push(SignedAggregatedAttestation { + data: att.data.clone(), + proof: combined, + }); + } + + // Insert into the new pool. `PayloadBuffer::push` auto-prunes any local + // partial whose participants are a strict subset of the combined proof, + // so explicit supersede tracking isn't needed. + if !store_inserts.is_empty() { + store.insert_new_aggregated_payloads_batch(store_inserts); + } + + aggregates +} + +struct Candidate { + idx: usize, + data_root: H256, + new_validators: usize, + local_partials: Vec, +} + +/// Identify attestations from a freshly imported block worth SNARK-splitting. +/// +/// A candidate is an attestation whose target outruns the store's justified +/// checkpoint and whose participants extend the local coverage for that +/// AttestationData. Candidates are sorted by uncovered-validator count and +/// capped at [`MAX_REAGGREGATIONS_PER_BLOCK`] so an attacker-shaped block +/// cannot blow past the slot budget. +fn select_candidates(store: &Store, attestations: &[AggregatedAttestation]) -> Vec { + let justified_slot = store.latest_justified().slot; + let mut candidates: Vec = Vec::new(); + for (idx, att) in attestations.iter().enumerate() { + if att.data.target.slot <= justified_slot { + continue; + } + let data_root = att.data.hash_tree_root(); + let (new, known) = store.existing_proofs_for_data(&data_root); + let mut local_union: HashSet = HashSet::new(); + for proof in new.iter().chain(known.iter()) { + local_union.extend(proof.participant_indices()); + } + let block_participants: HashSet = validator_indices(&att.aggregation_bits).collect(); + if block_participants.is_subset(&local_union) { + continue; + } + let mut local: Vec = new; + local.extend(known); + candidates.push(Candidate { + idx, + data_root, + new_validators: block_participants.difference(&local_union).count(), + local_partials: local, + }); + } + candidates.sort_by_key(|c| std::cmp::Reverse(c.new_validators)); + candidates.truncate(MAX_REAGGREGATIONS_PER_BLOCK); + candidates +} + +#[cfg(test)] +mod tests { + use super::*; + use ethlambda_storage::{Store, backend::InMemoryBackend}; + use ethlambda_types::{ + attestation::{AggregatedAttestation, AggregationBits, AttestationData}, + checkpoint::Checkpoint, + state::State, + }; + use std::sync::Arc; + + fn bits(indices: &[usize]) -> AggregationBits { + let max = indices.iter().copied().max().unwrap_or(0); + let mut b = AggregationBits::with_length(max + 1).unwrap(); + for &i in indices { + b.set(i, true).unwrap(); + } + b + } + + fn make_att(slot: u64, target_slot: u64, voters: &[usize]) -> AggregatedAttestation { + AggregatedAttestation { + aggregation_bits: bits(voters), + data: AttestationData { + slot, + head: Checkpoint::default(), + target: Checkpoint { + root: H256::ZERO, + slot: target_slot, + }, + source: Checkpoint::default(), + }, + } + } + + fn empty_store() -> Store { + let backend: Arc = Arc::new(InMemoryBackend::new()); + Store::from_anchor_state(backend, State::from_genesis(0, vec![])) + } + + #[test] + fn select_skips_target_at_or_below_justified() { + let mut store = empty_store(); + // Justified at slot 5; an attestation with target.slot = 5 must be skipped. + store.update_checkpoints(ethlambda_storage::ForkCheckpoints::new( + store.head(), + Some(Checkpoint { + root: H256::ZERO, + slot: 5, + }), + None, + )); + let candidates = select_candidates(&store, &[make_att(6, 5, &[0, 1])]); + assert!(candidates.is_empty()); + } + + #[test] + fn select_skips_when_block_participants_already_covered() { + let mut store = empty_store(); + let att = make_att(2, 2, &[0, 1]); + let hashed = HashedAttestationData::new(att.data.clone()); + // Seed the new-payload pool with a Type-1 covering validators {0, 1}. + store.insert_new_aggregated_payload(hashed, TypeOneMultiSignature::empty(bits(&[0, 1]))); + let candidates = select_candidates(&store, &[att]); + assert!(candidates.is_empty()); + } + + #[test] + fn select_keeps_attestation_with_new_voters() { + let mut store = empty_store(); + let att = make_att(2, 2, &[0, 1, 2]); + let hashed = HashedAttestationData::new(att.data.clone()); + // Local pool only covers validator 0. + store.insert_new_aggregated_payload(hashed, TypeOneMultiSignature::empty(bits(&[0]))); + let candidates = select_candidates(&store, &[att]); + assert_eq!(candidates.len(), 1); + // 1 and 2 are uncovered, so new_validators = 2. + assert_eq!(candidates[0].new_validators, 2); + assert_eq!(candidates[0].idx, 0); + } + + #[test] + fn select_caps_at_max_reaggregations_per_block() { + let store = empty_store(); + // Synthesize MAX + 5 attestations, each carrying a unique data root and + // distinct voters so none of the de-dup filters short-circuit. + let attestations: Vec = (0..MAX_REAGGREGATIONS_PER_BLOCK + 5) + .map(|i| make_att((i + 1) as u64, (i + 2) as u64, &[i * 2, i * 2 + 1])) + .collect(); + let candidates = select_candidates(&store, &attestations); + assert_eq!(candidates.len(), MAX_REAGGREGATIONS_PER_BLOCK); + } + + #[test] + fn select_prioritises_attestations_with_most_uncovered_voters() { + let store = empty_store(); + // Two attestations; one covers 1 new voter, the other covers 3. + let high = make_att(1, 2, &[0, 1, 2]); + let low = make_att(3, 4, &[5]); + let candidates = select_candidates(&store, &[low.clone(), high.clone()]); + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].new_validators, 3); + assert_eq!(candidates[0].idx, 1); + } +} From 4238a941e2a2ae48080b3f495995750b0945bc84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 20 May 2026 16:58:32 -0300 Subject: [PATCH 09/13] test(fixtures): bump leanSpec to post-#717, switch to test-scheme generation The pinned leanSpec commit bumps from 18fe71f (April 2026) to d9d2e67, just after PR #717 ("Aggregated block proof - devnet5") and the fork-choice fixture relocation under `lstar/` instead of `devnet/`. Fixture generation switches from `--scheme=prod` to `--scheme=test` because PR #725 ("refactor(xmss): native Pydantic for KeyPair") renamed the on-disk key JSON shape and regenerated `test_scheme/*.json` to match, but left the 12 `prod_scheme/*.json` files in the pre-#725 flat shape upstream. `--scheme=prod` is therefore broken at the filler level until upstream re-emits the prod keys. Rust-side fixture parsers updated for the PR #717 wire format: - `verify_signatures::TestSignedBlock` now reads `signedBlock.proof.data` as the raw merged Type-2 hex blob, and `try_into_signed_block_with_proofs` decodes it verbatim. No more devnet4-shaped per-attestation Type-1s plus a stand-alone proposer signature. - `fork_choice::ProofStepData` renames `proofData` to `proof` to match the PR #717 fixture field. `participants` continues to deserialize from `{ data: [bool, ...] }`. - The fork-choice spec runner and the Hive `test_driver` reach into `proof.proof` (was `proof.proof_data`) when materialising `SignedAggregatedAttestation` payloads. --- Makefile | 19 ++- .../blockchain/tests/forkchoice_spectests.rs | 2 +- .../common/test-fixtures/src/fork_choice.rs | 9 +- .../test-fixtures/src/verify_signatures.rs | 127 ++++++++---------- crates/net/rpc/src/test_driver.rs | 2 +- 5 files changed, 72 insertions(+), 87 deletions(-) diff --git a/Makefile b/Makefile index a406fbef..d88371fc 100644 --- a/Makefile +++ b/Makefile @@ -24,22 +24,21 @@ docker-build: ## 🐳 Build the Docker image -t ghcr.io/lambdaclass/ethlambda:$(DOCKER_TAG) . @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 +# 2026-05-20 +# Pinned just after leanSpec PR #717 ("Aggregated block proof - devnet5") +# merged: the wire format collapses per-attestation Type-1s + proposer +# signature into a single block-level Type-2 merged proof, and fork-choice +# fixtures live under `lstar/` instead of `devnet/`. Fixtures must be +# generated with `--scheme=test`; the `prod` scheme's pre-generated keys +# upstream still use the pre-#725 JSON shape and break the filler. +LEAN_SPEC_COMMIT_HASH:=d9d2e6779a4dcbecbe1cf2bdda47cd64f3f4844a leanSpec: git clone https://github.com/leanEthereum/leanSpec.git --single-branch cd leanSpec && git checkout $(LEAN_SPEC_COMMIT_HASH) leanSpec/fixtures: leanSpec - cd leanSpec && uv run fill --fork devnet -n auto --scheme=prod -o fixtures + cd leanSpec && uv run fill --fork Lstar -n auto --scheme=test -o fixtures lean-quickstart: git clone https://github.com/blockblaz/lean-quickstart.git --depth 1 --single-branch diff --git a/crates/blockchain/tests/forkchoice_spectests.rs b/crates/blockchain/tests/forkchoice_spectests.rs index 4ace2692..d0bd9f82 100644 --- a/crates/blockchain/tests/forkchoice_spectests.rs +++ b/crates/blockchain/tests/forkchoice_spectests.rs @@ -142,7 +142,7 @@ fn run(path: &Path) -> datatest_stable::Result<()> { let proof_fixture = att_data .proof .expect("gossipAggregatedAttestation step missing proof"); - let proof_bytes: Vec = proof_fixture.proof_data.into(); + let proof_bytes: Vec = proof_fixture.proof.into(); let proof_data = ByteList::try_from(proof_bytes) .expect("aggregated proof data fits in ByteList512KiB"); let data: AttestationData = att_data.data.into(); diff --git a/crates/common/test-fixtures/src/fork_choice.rs b/crates/common/test-fixtures/src/fork_choice.rs index a1c2d277..c31387ad 100644 --- a/crates/common/test-fixtures/src/fork_choice.rs +++ b/crates/common/test-fixtures/src/fork_choice.rs @@ -93,11 +93,16 @@ pub struct AttestationStepData { pub proof: Option, } +/// Aggregated-attestation proof carried by `gossipAggregatedAttestation` +/// steps (leanSpec PR #717 schema). +/// +/// `participants` arrives as `{ data: [bool, ...] }` and `proof` as +/// `{ data: "0x" }`; the latter is the lean-multisig Type-1 +/// `compress_without_pubkeys()` bytes for that AttestationData. #[derive(Debug, Clone, Deserialize)] pub struct ProofStepData { pub participants: AggregationBits, - #[serde(rename = "proofData")] - pub proof_data: HexByteList, + pub proof: HexByteList, } /// Hex-encoded byte list in the fixture format: `{ "data": "0xdeadbeef" }`. diff --git a/crates/common/test-fixtures/src/verify_signatures.rs b/crates/common/test-fixtures/src/verify_signatures.rs index 8cffe8a4..f374c590 100644 --- a/crates/common/test-fixtures/src/verify_signatures.rs +++ b/crates/common/test-fixtures/src/verify_signatures.rs @@ -1,18 +1,23 @@ -//! Signature-verification test fixture types. +//! Signature-verification test fixture types (leanSpec PR #717 schema). //! //! Used both by the offline spec-test runner and the Hive -//! `/lean/v0/test_driver/verify_signatures/run` endpoint, which receives the +//! `/lean/v0/test_driver/verify_signatures/run` endpoint, which receive the //! same JSON shapes from the lean spec-assets simulator. +//! +//! Fixture shape after PR #717: +//! +//! signedBlock: +//! block: {...standard block fields...} +//! proof: { data: "0x" } -use crate::{AggregationBits, Block, Container, TestInfo, TestState, deser_xmss_hex}; -use ethlambda_types::attestation::XmssSignature; +use crate::{Block, TestInfo, TestState}; use ethlambda_types::block::{ByteList512KiB, SignedBlock}; use serde::Deserialize; use std::collections::HashMap; use std::fmt; use std::path::Path; -/// Root struct for verify signatures test vectors +/// Root struct for verify signatures test vectors. #[derive(Debug, Clone, Deserialize)] pub struct VerifySignaturesTestVector { #[serde(flatten)] @@ -20,7 +25,6 @@ pub struct VerifySignaturesTestVector { } impl VerifySignaturesTestVector { - /// Load a verify signatures test vector from a JSON file pub fn from_file>(path: P) -> Result> { let content = std::fs::read_to_string(path)?; let test_vector = serde_json::from_str(&content)?; @@ -28,7 +32,7 @@ impl VerifySignaturesTestVector { } } -/// A single verify signatures test case +/// A single verify-signatures test case. #[derive(Debug, Clone, Deserialize)] pub struct VerifySignaturesTest { #[allow(dead_code)] @@ -47,96 +51,73 @@ pub struct VerifySignaturesTest { pub info: TestInfo, } -// ============================================================================ -// Signed Block Types -// ============================================================================ - -/// Signed block with signature bundle (devnet4: no proposer attestation wrapper) +/// Fixture-side signed block: a block plus its raw merged Type-2 proof bytes. #[derive(Debug, Clone, Deserialize)] pub struct TestSignedBlock { #[serde(alias = "message")] pub block: Block, - pub signature: TestSignatureBundle, + pub proof: HexBytes, } -/// Lossy fixture-to-SignedBlock conversion: every signature byte is dropped. -/// -/// Under the leanSpec PR #717 wire format the block envelope carries a single -/// opaque merged proof blob, not per-attestation Type-1s plus a proposer -/// signature. Existing devnet4-shaped fixtures predate that change and don't -/// ship a merged Type-2 blob — they ship per-attestation signature metadata -/// plus a raw XMSS proposer signature. There is no way to reconstruct the -/// merged Type-2 blob from those bytes without running the lean-multisig -/// prover, so this `From` impl yields an empty `proof` and the resulting -/// block fails real `verify_block_signatures`. Callers that don't reach the -/// verifier (fixtures with `expectException`) are unaffected. -impl From for SignedBlock { - fn from(value: TestSignedBlock) -> Self { - SignedBlock { - message: value.block.into(), - proof: ByteList512KiB::default(), - } +/// `{ "data": "0x..." }` wrapper used by leanSpec fixtures for byte fields. +#[derive(Debug, Clone, Deserialize)] +pub struct HexBytes { + pub data: String, +} + +impl HexBytes { + pub fn decode(&self) -> Result, hex::FromHexError> { + let s = self.data.strip_prefix("0x").unwrap_or(&self.data); + hex::decode(s) } } /// Error returned by [`TestSignedBlock::try_into_signed_block_with_proofs`]. #[derive(Debug)] pub enum SignedBlockConvertError { - /// Devnet4-shaped fixtures cannot be converted to the leanSpec PR #717 - /// wire format without rebuilding the merged Type-2 proof through the - /// lean-multisig prover. Until fixtures ship the merged proof blob - /// directly, the Hive driver path returns this error. - LegacyFixtureNotConvertible, + InvalidProofHex(String), + ProofTooLarge(usize), } impl fmt::Display for SignedBlockConvertError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::LegacyFixtureNotConvertible => { - f.write_str("fixture predates leanSpec PR #717 — merged Type-2 proof unavailable") - } + Self::InvalidProofHex(reason) => write!(f, "proof.data hex decode failed: {reason}"), + Self::ProofTooLarge(len) => write!(f, "proof bytes exceed cap: {len}"), } } } impl std::error::Error for SignedBlockConvertError {} -impl TestSignedBlock { - /// Materialize a `SignedBlock` that preserves the fixture-supplied - /// merged proof bytes. Until fixtures are updated to ship the merged - /// Type-2 proof blob directly (post leanSpec PR #717), this returns - /// [`SignedBlockConvertError::LegacyFixtureNotConvertible`]. - pub fn try_into_signed_block_with_proofs(self) -> Result { - let _ = self; - Err(SignedBlockConvertError::LegacyFixtureNotConvertible) +/// Lossy fixture-to-SignedBlock conversion that preserves the merged proof. +/// +/// The conversion is fallible because the proof bytes may not decode as hex +/// or may exceed the wire cap. Tests with `expectException` set tolerate +/// failures upstream; the From impl panics so test runners get a clear +/// signal when fixture shape drifts. +impl From for SignedBlock { + fn from(value: TestSignedBlock) -> Self { + value + .try_into_signed_block_with_proofs() + .expect("fixture proof decode") } } -// ============================================================================ -// Signature Types -// ============================================================================ - -/// Bundle of signatures for block and attestations -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct TestSignatureBundle { - #[serde(rename = "proposerSignature", deserialize_with = "deser_xmss_hex")] - pub proposer_signature: XmssSignature, - #[serde(rename = "attestationSignatures")] - pub attestation_signatures: Container, -} - -/// Attestation signature from a validator -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct AttestationSignature { - pub participants: AggregationBits, - #[serde(rename = "proofData")] - pub proof_data: ProofData, -} - -/// Placeholder for future SNARK proof data -#[derive(Debug, Clone, Deserialize)] -pub struct ProofData { - pub data: String, +impl TestSignedBlock { + /// Materialize a `SignedBlock` preserving the fixture-supplied merged + /// Type-2 proof bytes verbatim. + 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 = ByteList512KiB::try_from(bytes) + .map_err(|_| SignedBlockConvertError::ProofTooLarge(len))?; + Ok(SignedBlock { + message: self.block.into(), + proof, + }) + } } diff --git a/crates/net/rpc/src/test_driver.rs b/crates/net/rpc/src/test_driver.rs index e62de98b..1a19f4f4 100644 --- a/crates/net/rpc/src/test_driver.rs +++ b/crates/net/rpc/src/test_driver.rs @@ -386,7 +386,7 @@ fn apply_step(store: &mut Store, step: ForkChoiceStep) -> Result<(), String> { .proof .ok_or_else(|| "gossipAggregatedAttestation step missing proof".to_string())?; let participants: EthAggregationBits = proof.participants.into(); - let proof_bytes: Vec = proof.proof_data.into(); + let proof_bytes: Vec = proof.proof.into(); let proof_data = ByteList512KiB::try_from(proof_bytes) .map_err(|err| format!("aggregated proof data too large: {err:?}"))?; let data: ethlambda_types::attestation::AttestationData = att.data.into(); From 961aba40fc1b304ca61d46c45b6a485776d87849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 20 May 2026 18:37:48 -0300 Subject: [PATCH 10/13] fix(types): restore SSZ proof envelope, drop MAX_ATTESTATIONS_DATA to 8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The leanSpec PR #717 wire format is not raw lean-multisig bytes — SignedBlock.proof is the SSZ-encoded form of a thin TypeTwoMultiSignature { proof: ByteList512KiB } container. On the wire that container collapses to `[4-byte LE offset = 4][type2_wire]`, so every cross-client implementation prepends a 4-byte SSZ offset header in front of the raw merged proof bytes the Rust verifier passed back through `lz4_postcard_decode`. Without the header the lean-multisig decompressor returns None and we surface `DeserializationFailed` — verified end-to-end by reproducing the failure from Python against the same fixture. This commit: - Adds `SignedBlock::wrap_merged_proof` / `SignedBlock::merged_proof_bytes` helpers that prepend / strip the 4-byte SSZ offset header so callers don't repeat the magic constant. - Updates the block builder (`propose_block`) to wrap the merge output via `SignedBlock::wrap_merged_proof` before stashing in the envelope. - Updates `verify_block_signatures` and `reaggregate_from_block` to strip the wrapper via `merged_proof_bytes` before feeding the bytes to `verify_type_2_signature` / `split_type_2_by_message`. - Changes those crypto wrappers to take `&[u8]` instead of `&ByteList512KiB` so the byte-slice flows through without an extra SSZ wrap. - Lowers `MAX_ATTESTATIONS_DATA` from 16 to 8 to match leanSpec PR #717, which tightened the cap alongside the merged-proof refactor. - Updates the Hive `verify_signatures` driver e2e fixture to the new `signedBlock.proof.data` schema (no more `signature.proposerSignature` / `attestationSignatures` shape) and points the proof at an empty blob since the test reaches the proposer-bound check before the SNARK. Full workspace: 419 tests passing across all suites, including the previously-failing 16 forkchoice and 5 signature spec tests now wired to leanSpec d9d2e67 prod-scheme fixtures. --- crates/blockchain/src/lib.rs | 21 +++++-- crates/blockchain/src/reaggregate.rs | 9 ++- crates/blockchain/src/store.rs | 13 ++++- crates/common/crypto/src/lib.rs | 15 +++-- crates/common/types/src/block.rs | 74 ++++++++++++++++++++++--- crates/net/rpc/tests/test_driver_e2e.rs | 9 +-- 6 files changed, 112 insertions(+), 29 deletions(-) diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index c76c2a38..73919b1c 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -427,12 +427,13 @@ impl BlockChainServer { } merge_inputs.push((vec![proposer_pubkey], proposer_proof_bytes)); - // Merge yields raw lean-multisig Type-2 bytes; the block envelope - // carries them directly with no Rust-side SSZ wrapper (leanSpec PR - // #717 wire format). 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 proof_bytes = match ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) { + // 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 + // `block.proposer_index`, so nothing else needs persisting. + let merged_bytes = match ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) { Ok(bytes) => bytes, Err(err) => { error!(%slot, %validator_id, %err, "Failed to merge Type-1s into Type-2"); @@ -440,6 +441,14 @@ impl BlockChainServer { return; } }; + let proof_bytes = match SignedBlock::wrap_merged_proof(merged_bytes.iter().as_slice()) { + Ok(p) => p, + Err(err) => { + error!(%slot, %validator_id, %err, "Failed to wrap merged proof envelope"); + metrics::inc_block_building_failures(); + return; + } + }; // `type_one_proofs` is no longer needed past this point. drop(type_one_proofs); let signed_block = SignedBlock { diff --git a/crates/blockchain/src/reaggregate.rs b/crates/blockchain/src/reaggregate.rs index 53c887ae..b0bdd1b0 100644 --- a/crates/blockchain/src/reaggregate.rs +++ b/crates/blockchain/src/reaggregate.rs @@ -119,9 +119,14 @@ pub fn reaggregate_from_block( }; // Step 1: SNARK-split this attestation's component out of the - // block's merged Type-2 proof. + // 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(); + }; let split_bytes = match ethlambda_crypto::split_type_2_by_message( - &signed_block.proof, + merged_bytes, pubkeys_per_component.clone(), &data_root, ) { diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index f162751e..9007e8fd 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -1337,9 +1337,20 @@ 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 crypto_start = std::time::Instant::now(); ethlambda_crypto::verify_type_2_signature( - &signed_block.proof, + merged_bytes, pubkeys_per_component, &expected_bindings, ) diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index cdf129d2..222f0e07 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -319,7 +319,7 @@ pub fn merge_type_1s_into_type_2( /// caller-supplied lists, checks they match what the proof binds, and then runs /// the inner SNARK verifier. pub fn verify_type_2_signature( - proof_data: &ByteList512KiB, + proof_data: &[u8], pubkeys_per_component: Vec>, expected_bindings: &[(H256, u32)], ) -> Result<(), VerificationError> { @@ -337,7 +337,7 @@ pub fn verify_type_2_signature( .map(into_lean_pubkeys) .collect(); - let sig = LMType2::decompress_without_pubkeys(proof_data.iter().as_slice(), pubkeys_per_info) + let sig = LMType2::decompress_without_pubkeys(proof_data, pubkeys_per_info) .ok_or(VerificationError::DeserializationFailed)?; if sig.info.len() != expected_bindings.len() { @@ -376,7 +376,7 @@ pub fn verify_type_2_signature( /// decompressed proof. Returns the `compress_without_pubkeys()` form of the /// resulting Type-1. pub fn split_type_2_by_message( - proof_data: &ByteList512KiB, + proof_data: &[u8], pubkeys_per_component: Vec>, message: &H256, ) -> Result { @@ -387,9 +387,8 @@ pub fn split_type_2_by_message( .map(into_lean_pubkeys) .collect(); - let type_2 = - LMType2::decompress_without_pubkeys(proof_data.iter().as_slice(), pubkeys_per_info) - .ok_or(AggregationError::DeserializationFailed)?; + let type_2 = LMType2::decompress_without_pubkeys(proof_data, pubkeys_per_info) + .ok_or(AggregationError::DeserializationFailed)?; let matches: Vec = type_2 .info @@ -583,14 +582,14 @@ mod tests { .expect("merge"); verify_type_2_signature( - &merged, + merged.iter().as_slice(), vec![vec![pk_a.clone()], vec![pk_b.clone()]], &[(msg_a, slot_a), (msg_b, slot_b)], ) .expect("verify type-2"); let split = split_type_2_by_message( - &merged, + merged.iter().as_slice(), vec![vec![pk_a.clone()], vec![pk_b.clone()]], &msg_a, ) diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index ed5346c5..3b5f0d83 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -14,11 +14,11 @@ use primitives::HashTreeRoot as _; /// Envelope carrying a block and the single merged proof binding every /// signature it depends on. /// -/// `proof` holds the raw `compress_without_pubkeys()` form of the -/// lean-multisig `TypeTwoMultiSignature` covering, in order, every -/// per-attestation Type-1 plus a singleton Type-1 for the proposer's -/// signature over the block root. This matches leanSpec PR #717's wire -/// format exactly: no Rust-side SSZ wrapper around the lean-multisig bytes. +/// `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). /// ///
/// @@ -33,10 +33,67 @@ pub struct SignedBlock { /// The block being signed. pub message: Block, - /// Raw lean-multisig Type-2 merged proof bytes (no Rust-side SSZ wrapper). + /// 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. 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..]) + } + + /// 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)) + } +} + +/// Errors returned by the [`SignedBlock`] proof-envelope helpers. +#[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), +} + +impl core::fmt::Display for ProofEnvelopeError { + 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"), + } + } +} + +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 { @@ -70,8 +127,9 @@ pub type ByteList512KiB = ByteList<524_288>; /// 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; +/// See: leanSpec PR #717, which lowered the cap from 16 to 8 alongside the +/// merged block-proof refactor. +pub const MAX_ATTESTATIONS_DATA: usize = 8; /// A Type-1 single-message proof aggregating signatures from many validators. /// diff --git a/crates/net/rpc/tests/test_driver_e2e.rs b/crates/net/rpc/tests/test_driver_e2e.rs index 687ccf31..5051741e 100644 --- a/crates/net/rpc/tests/test_driver_e2e.rs +++ b/crates/net/rpc/tests/test_driver_e2e.rs @@ -249,6 +249,10 @@ async fn verify_signatures_with_empty_validator_set_fails_cleanly() { // proposer (no validators in the set). The driver should return // succeeded:false with a descriptive error, matching the simulator's // expectException path. + // + // The proof blob is empty (`0x`): the verifier rejects the proposer-index + // bound before reaching the SNARK decode, so the bytes content doesn't + // matter for this test. let signed_block = json!({ "message": { "slot": 1, @@ -257,10 +261,7 @@ async fn verify_signatures_with_empty_validator_set_fails_cleanly() { "stateRoot": ZERO_ROOT, "body": {"attestations": {"data": []}}, }, - "signature": { - "proposerSignature": "0x".to_string() + &"00".repeat(ethlambda_types::signature::SIGNATURE_SIZE), - "attestationSignatures": {"data": []}, - }, + "proof": {"data": "0x"}, }); let body = json!({ From a0b9677621a24c9ea63e902dbb34d2b4873453f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 21 May 2026 12:05:28 -0300 Subject: [PATCH 11/13] chore: bump leanSpec commit --- Makefile | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index d88371fc..3b471c25 100644 --- a/Makefile +++ b/Makefile @@ -24,21 +24,15 @@ docker-build: ## 🐳 Build the Docker image -t ghcr.io/lambdaclass/ethlambda:$(DOCKER_TAG) . @echo -# 2026-05-20 -# Pinned just after leanSpec PR #717 ("Aggregated block proof - devnet5") -# merged: the wire format collapses per-attestation Type-1s + proposer -# signature into a single block-level Type-2 merged proof, and fork-choice -# fixtures live under `lstar/` instead of `devnet/`. Fixtures must be -# generated with `--scheme=test`; the `prod` scheme's pre-generated keys -# upstream still use the pre-#725 JSON shape and break the filler. -LEAN_SPEC_COMMIT_HASH:=d9d2e6779a4dcbecbe1cf2bdda47cd64f3f4844a +# 2026-05-21 +LEAN_SPEC_COMMIT_HASH:=825bec6bf278920cfc56730d64a7c90522a0bb6c leanSpec: git clone https://github.com/leanEthereum/leanSpec.git --single-branch cd leanSpec && git checkout $(LEAN_SPEC_COMMIT_HASH) leanSpec/fixtures: leanSpec - cd leanSpec && uv run fill --fork Lstar -n auto --scheme=test -o fixtures + cd leanSpec && uv run fill --fork Lstar -n auto --scheme=prod -o fixtures lean-quickstart: git clone https://github.com/blockblaz/lean-quickstart.git --depth 1 --single-branch From f00646e438c7768ced5fc55533d4c0c946e74fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 21 May 2026 15:23:29 -0300 Subject: [PATCH 12/13] ci: update fork name in fixture generation --- .github/workflows/ci.yml | 2 +- Makefile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0c70608..770aa3b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,7 +120,7 @@ jobs: - name: Generate test fixtures if: steps.cache-fixtures.outputs.cache-hit != 'true' working-directory: leanSpec - run: uv run fill --fork=Devnet --scheme prod -o fixtures -n auto + run: uv run fill --fork Lstar -n auto --scheme prod -o fixtures # Save fixtures even if a later step fails, so a re-run does not # have to regenerate them. See: https://github.com/actions/cache/tree/main/save#always-save-cache diff --git a/Makefile b/Makefile index 3b471c25..8568d137 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ leanSpec: cd leanSpec && git checkout $(LEAN_SPEC_COMMIT_HASH) leanSpec/fixtures: leanSpec - cd leanSpec && uv run fill --fork Lstar -n auto --scheme=prod -o fixtures + cd leanSpec && uv run fill --fork Lstar -n auto --scheme prod -o fixtures lean-quickstart: git clone https://github.com/blockblaz/lean-quickstart.git --depth 1 --single-branch From df54c78871febdc5500b2a09b88156fad4e5a5b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 21 May 2026 15:24:58 -0300 Subject: [PATCH 13/13] chore: remove comment --- crates/blockchain/src/lib.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 73919b1c..8db7505a 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -354,10 +354,6 @@ impl BlockChainServer { // Assemble SignedBlock: wrap the proposer's raw XMSS signature into a // singleton Type-1 SNARK, then merge it with every attestation Type-1 // into the block's single Type-2 proof. - // - // Both proofs run synchronously on the actor thread, so propose_block - // currently dominates the slot budget; see PR #370 for the off-thread - // refactor follow-up. let head_state = self.store.head_state(); let validators = &head_state.validators; let Some(proposer_validator) = validators.get(validator_id as usize) else {