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 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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 {