diff --git a/Cargo.lock b/Cargo.lock index c99f8aa1..fbcf55bc 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", @@ -3468,6 +3468,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" @@ -3731,7 +3750,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", @@ -3739,16 +3758,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", @@ -3761,7 +3784,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", @@ -3770,6 +3793,7 @@ dependencies = [ "pest", "pest_derive", "rand 0.10.0", + "serde", "sub_protocols", "tracing", "utils", @@ -3778,7 +3802,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", @@ -3834,7 +3858,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", @@ -4875,7 +4899,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", @@ -4884,7 +4908,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", @@ -4892,12 +4916,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", @@ -4912,7 +4937,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", @@ -4928,7 +4953,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", @@ -4936,12 +4961,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", @@ -4954,7 +4980,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", @@ -4964,7 +4990,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", ] @@ -4972,7 +4998,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", @@ -4984,6 +5010,7 @@ dependencies = [ "mt-utils", "rand 0.10.0", "rayon", + "system-info", "tracing", ] @@ -5303,6 +5330,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" @@ -6471,14 +6508,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", @@ -6486,6 +6526,7 @@ dependencies = [ "sub_protocols", "tracing", "utils", + "zk-alloc", ] [[package]] @@ -7503,7 +7544,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", @@ -7594,6 +7635,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" @@ -8138,7 +8188,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", @@ -9095,6 +9145,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/Makefile b/Makefile index a406fbef..d88371fc 100644 --- a/Makefile +++ b/Makefile @@ -24,22 +24,21 @@ docker-build: ## 🐳 Build the Docker image -t ghcr.io/lambdaclass/ethlambda:$(DOCKER_TAG) . @echo -# 2026-04-29 -# NOTE(type1-type2): an attempted bump to anshalshukla/leanSpec@0ab09dd ("dummy -# type 1 and type 2 aggregation with block proofs") was reverted because the -# testing harness in that branch still imports `AttestationSignatures`, which -# the same commit removed — the fixture generator fails to load. We stay on -# the canonical commit and skip the affected SSZ-spec and signature-spec test -# cases until the upstream refactor lands together with matching testing-side -# updates. -LEAN_SPEC_COMMIT_HASH:=18fe71fee49f8865a5c8a4cb8b1787b0cbc9e25b +# 2026-05-20 +# Pinned just after leanSpec PR #717 ("Aggregated block proof - devnet5") +# merged: the wire format collapses per-attestation Type-1s + proposer +# signature into a single block-level Type-2 merged proof, and fork-choice +# fixtures live under `lstar/` instead of `devnet/`. Fixtures must be +# generated with `--scheme=test`; the `prod` scheme's pre-generated keys +# upstream still use the pre-#725 JSON shape and break the filler. +LEAN_SPEC_COMMIT_HASH:=d9d2e6779a4dcbecbe1cf2bdda47cd64f3f4844a leanSpec: git clone https://github.com/leanEthereum/leanSpec.git --single-branch cd leanSpec && git checkout $(LEAN_SPEC_COMMIT_HASH) leanSpec/fixtures: leanSpec - cd leanSpec && uv run fill --fork devnet -n auto --scheme=prod -o fixtures + cd leanSpec && uv run fill --fork Lstar -n auto --scheme=test -o fixtures lean-quickstart: git clone https://github.com/blockblaz/lean-quickstart.git --depth 1 --single-branch diff --git a/crates/blockchain/src/aggregation.rs b/crates/blockchain/src/aggregation.rs index b48390fb..7f6f7427 100644 --- a/crates/blockchain/src/aggregation.rs +++ b/crates/blockchain/src/aggregation.rs @@ -13,7 +13,7 @@ use ethlambda_crypto::aggregate_mixed; use ethlambda_storage::Store; use ethlambda_types::{ attestation::{AggregationBits, HashedAttestationData}, - block::{ByteListMiB, BytecodeClaim, TypeOneInfo, TypeOneMultiSignature}, + block::{ByteList512KiB, TypeOneMultiSignature}, primitives::H256, signature::{ValidatorPublicKey, ValidatorSignature}, state::Validator, @@ -46,7 +46,7 @@ pub struct AggregationJob { pub(crate) slot: u64, /// Pre-resolved `(participant_pubkeys, proof_data)` pairs for children /// selected via greedy coverage. - pub(crate) children: Vec<(Vec, ByteListMiB)>, + pub(crate) children: Vec<(Vec, ByteList512KiB)>, pub(crate) accepted_child_ids: Vec, pub(crate) raw_pubkeys: Vec, pub(crate) raw_sigs: Vec, @@ -234,7 +234,7 @@ fn build_job( fn resolve_child_pubkeys( child_proofs: &[TypeOneMultiSignature], validators: &[Validator], -) -> (Vec<(Vec, ByteListMiB)>, Vec) { +) -> (Vec<(Vec, ByteList512KiB)>, Vec) { let mut children = Vec::with_capacity(child_proofs.len()); let mut accepted_child_ids: Vec = Vec::new(); @@ -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 163b9f0f..73919b1c 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -8,10 +8,10 @@ use ethlambda_types::{ ShortRoot, aggregator::AggregatorController, attestation::{SignedAggregatedAttestation, SignedAttestation}, - block::{ByteListMiB, SignedBlock, TypeOneMultiSignature, TypeTwoMultiSignature}, + block::{ByteList512KiB, SignedBlock}, primitives::{H256, HashTreeRoot as _}, + signature::{ValidatorPublicKey, ValidatorSignature}, }; -use libssz::SszEncode as _; use crate::aggregation::{ AGGREGATION_DEADLINE, AggregateProduced, AggregationDeadline, AggregationDone, @@ -31,6 +31,7 @@ pub mod aggregation; pub(crate) mod fork_choice_tree; pub mod key_manager; pub mod metrics; +pub mod reaggregate; pub mod store; pub struct BlockChain { @@ -350,22 +351,106 @@ 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, - ); - 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"); + // Assemble SignedBlock: wrap the proposer's raw XMSS signature into a + // singleton Type-1 SNARK, then merge it with every attestation Type-1 + // into the block's single Type-2 proof. + // + // Both proofs run synchronously on the actor thread, so propose_block + // currently dominates the slot budget; see PR #370 for the off-thread + // refactor follow-up. + let head_state = self.store.head_state(); + let validators = &head_state.validators; + let Some(proposer_validator) = validators.get(validator_id as usize) else { + error!(%slot, %validator_id, "Proposer index out of range when assembling block"); + metrics::inc_block_building_failures(); + return; + }; + + // Decode the proposer's proposal pubkey once and reuse it both for the + // singleton Type-1 wrap and for the Type-2 merge inputs. + let Ok(proposer_pubkey) = proposer_validator.get_proposal_pubkey().inspect_err( + |err| error!(%slot, %validator_id, %err, "Failed to decode proposer proposal pubkey"), + ) else { + metrics::inc_block_building_failures(); + return; + }; + + let Ok(proposer_validator_signature) = + ValidatorSignature::from_bytes(&proposer_signature).inspect_err(|err| { + error!(%slot, %validator_id, %err, "Failed to decode proposer signature bytes") + }) + else { + metrics::inc_block_building_failures(); + return; + }; + let Ok(proposer_proof_bytes) = ethlambda_crypto::aggregate_signatures( + vec![proposer_pubkey.clone()], + vec![proposer_validator_signature], + &block_root, + slot as u32, + ) + .inspect_err( + |err| error!(%slot, %validator_id, %err, "Failed to wrap proposer signature as Type-1"), + ) else { + metrics::inc_block_building_failures(); + return; + }; + + let mut merge_inputs: Vec<(Vec, ByteList512KiB)> = + Vec::with_capacity(type_one_proofs.len() + 1); + let mut resolve_failed = false; + for t1 in &type_one_proofs { + let mut pubkeys = Vec::new(); + for vid in t1.participant_indices() { + let Some(validator) = validators.get(vid as usize) else { + error!(%slot, %validator_id, vid, "Participant out of range while resolving pubkeys"); + resolve_failed = true; + break; + }; + match validator.get_attestation_pubkey() { + Ok(pk) => pubkeys.push(pk), + Err(err) => { + error!(%slot, %validator_id, vid, %err, "Failed to decode attestation pubkey"); + 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_proof_bytes)); + + // Merge yields raw lean-multisig Type-2 bytes; wrap them in the + // thin SSZ container the spec uses (`[4-byte offset][type2_wire]`) + // before stashing into the block envelope (leanSpec PR #717 wire + // format). Per-component participants are rederived at verify time + // from `block.body.attestations[i].aggregation_bits` plus + // `block.proposer_index`, so nothing else needs persisting. + let merged_bytes = match ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) { + Ok(bytes) => bytes, + Err(err) => { + error!(%slot, %validator_id, %err, "Failed to merge Type-1s into Type-2"); + metrics::inc_block_building_failures(); + return; + } + }; + let proof_bytes = match SignedBlock::wrap_merged_proof(merged_bytes.iter().as_slice()) { + Ok(p) => p, + Err(err) => { + error!(%slot, %validator_id, %err, "Failed to wrap merged proof envelope"); + metrics::inc_block_building_failures(); + return; + } + }; + // `type_one_proofs` is no longer needed past this point. + drop(type_one_proofs); let signed_block = SignedBlock { message: block, proof: proof_bytes, @@ -390,7 +475,9 @@ impl BlockChainServer { info!(%slot, %validator_id, "Published block"); } - fn process_block(&mut self, signed_block: SignedBlock) -> Result<(), StoreError> { + /// Run block import, refresh metrics, and report whether the node is in + /// sync with the wall-clock slot after the import. + fn process_block(&mut self, signed_block: SignedBlock) -> Result { store::on_block(&mut self.store, signed_block)?; let head_slot = self.store.head_slot(); metrics::update_head_slot(head_slot); @@ -400,7 +487,8 @@ impl BlockChainServer { // Update sync status based on head slot vs wall clock slot let current_slot = self.store.time() / INTERVALS_PER_SLOT; - let status = if head_slot >= current_slot { + let synced = head_slot >= current_slot; + let status = if synced { metrics::SyncStatus::Synced } else { metrics::SyncStatus::Syncing @@ -410,7 +498,7 @@ impl BlockChainServer { for table in ALL_TABLES { metrics::update_table_bytes(table.name(), self.store.estimate_table_bytes(table)); } - Ok(()) + Ok(synced) } /// Process a newly received block. @@ -528,9 +616,12 @@ impl BlockChainServer { return; } - // Parent exists, proceed with processing + // Parent exists, proceed with processing. Clone the block so we + // can run post-import reaggregation against its merged proof — + // `process_block` consumes the original for the storage layer. + let block_for_reaggregate = signed_block.clone(); match self.process_block(signed_block) { - Ok(_) => { + Ok(synced) => { info!( %slot, proposer, @@ -539,6 +630,17 @@ impl BlockChainServer { "Block imported successfully" ); + // Recover per-attestation Type-1 proofs from the block's + // merged Type-2 and fold them into the local pool. Only + // run when the chain is in sync — backfilling nodes must + // not spam gossip with rederived aggregates. Non-validator + // nodes still benefit from the store update because the + // recovered proofs feed fork choice on the next acceptance + // tick. + if synced { + self.run_reaggregate_from_block(&block_for_reaggregate); + } + // Enqueue any pending blocks that were waiting for this parent self.collect_pending_children(block_root, queue); } @@ -555,6 +657,32 @@ impl BlockChainServer { } } + /// Run the post-import reaggregation pass and publish the resulting + /// aggregates when this node is in the aggregator role. + fn run_reaggregate_from_block(&mut self, signed_block: &SignedBlock) { + let aggregates = reaggregate::reaggregate_from_block(&mut self.store, signed_block); + if aggregates.is_empty() { + return; + } + let count = aggregates.len(); + let is_aggregator = self.aggregator.is_enabled(); + info!( + count, + is_aggregator, "Reaggregated block-borne attestations" + ); + if !is_aggregator { + return; + } + let Some(ref p2p) = self.p2p else { + return; + }; + for aggregate in aggregates { + let _ = p2p + .publish_aggregated_attestation(aggregate) + .inspect_err(|err| warn!(%err, "Failed to publish reaggregated attestation")); + } + } + fn request_missing_block(&mut self, block_root: H256) { // Send request to P2P layer (deduplication handled by P2P module) if let Some(ref p2p) = self.p2p { diff --git a/crates/blockchain/src/reaggregate.rs b/crates/blockchain/src/reaggregate.rs new file mode 100644 index 00000000..b0bdd1b0 --- /dev/null +++ b/crates/blockchain/src/reaggregate.rs @@ -0,0 +1,379 @@ +//! Reaggregate-from-block: recover per-attestation Type-1 proofs from a +//! freshly imported block's merged Type-2 proof and fold them into the local +//! aggregated-payload pool. +//! +//! Mirrors leanSpec PR #717 `SyncService._deconstruct_block_into_store`. +//! Required for catching-up nodes (and aggregators) to surface block-borne +//! votes to the rest of the network — without this, a validator that only +//! observed an attestation through a block can't republish it on gossip. +//! +//! ## Cost +//! +//! Each `split_type_2_by_message` runs a fresh SNARK. We bound the worst +//! case by: +//! +//! 1. Only deconstructing when the chain is in sync — backfilling nodes +//! must not flood gossip with rederived aggregates. +//! 2. Skipping attestations whose target is at or behind the store's +//! justified checkpoint — they carry no fork-choice value. +//! 3. Skipping attestations whose participants are already a subset of the +//! local union for that data — nothing to recover. +//! 4. Capping the number of splits per imported block at +//! [`MAX_REAGGREGATIONS_PER_BLOCK`] so an attacker-shaped block cannot +//! blow past the slot budget. + +use std::collections::HashSet; + +use ethlambda_storage::Store; +use ethlambda_types::{ + attestation::{ + AggregatedAttestation, HashedAttestationData, SignedAggregatedAttestation, + validator_indices, + }, + block::{SignedBlock, TypeOneMultiSignature}, + primitives::{H256, HashTreeRoot as _}, + signature::ValidatorPublicKey, +}; +use tracing::{debug, warn}; + +/// Maximum number of attestations whose Type-1 we will SNARK-split out of +/// any single imported block. Each split runs a fresh recursive SNARK +/// (~hundreds of ms) so the cap keeps block-import latency predictable. +pub const MAX_REAGGREGATIONS_PER_BLOCK: usize = 4; + +/// Recover per-attestation Type-1 proofs from a freshly imported block. +/// +/// Returns the combined aggregates that gained new validator coverage; the +/// caller publishes them on gossip when this node acts as an aggregator. +/// Always updates the store regardless of role so non-aggregator nodes +/// still get the fork-choice weight from block-imported votes. +pub fn reaggregate_from_block( + store: &mut Store, + signed_block: &SignedBlock, +) -> Vec { + let block = &signed_block.message; + let attestations: Vec = + block.body.attestations.iter().cloned().collect(); + if attestations.is_empty() { + return Vec::new(); + } + + // The Type-2 proof was built against the parent state's validator set. + // Without it we cannot resolve the pubkey layout the SNARK was bound to. + let Some(parent_state) = store.get_state(&block.parent_root) else { + debug!( + block_root = %ethlambda_types::ShortRoot(&block.hash_tree_root().0), + "Skipping reaggregation: parent state missing" + ); + return Vec::new(); + }; + let validators = &parent_state.validators; + let num_validators = validators.len() as u64; + + // Per-component pubkeys: one entry per body attestation in order, then + // the proposer entry. Layout is invariant per block, so it's resolved + // once and reused for every split call below. + let mut pubkeys_per_component: Vec> = + Vec::with_capacity(attestations.len() + 1); + for att in &attestations { + let mut pubkeys = Vec::new(); + for vid in validator_indices(&att.aggregation_bits) { + if vid >= num_validators { + warn!(vid, "Reaggregation aborted: participant out of range"); + return Vec::new(); + } + let Ok(pk) = validators[vid as usize].get_attestation_pubkey() else { + warn!(vid, "Reaggregation aborted: bad attestation pubkey"); + return Vec::new(); + }; + pubkeys.push(pk); + } + pubkeys_per_component.push(pubkeys); + } + if block.proposer_index >= num_validators { + return Vec::new(); + } + let Ok(proposer_pubkey) = validators[block.proposer_index as usize].get_proposal_pubkey() + else { + return Vec::new(); + }; + pubkeys_per_component.push(vec![proposer_pubkey]); + + let candidates = select_candidates(store, &attestations); + if candidates.is_empty() { + return Vec::new(); + } + + // Run the splits and merges. A failure on one attestation is logged + // and skipped — partial progress still surfaces useful aggregates. + let mut aggregates: Vec = Vec::with_capacity(candidates.len()); + let mut store_inserts: Vec<(HashedAttestationData, TypeOneMultiSignature)> = + Vec::with_capacity(candidates.len()); + + for candidate in candidates { + let att = &attestations[candidate.idx]; + let data_root = candidate.data_root; + let slot_u32: u32 = match att.data.slot.try_into() { + Ok(s) => s, + Err(_) => continue, + }; + + // Step 1: SNARK-split this attestation's component out of the + // block's merged Type-2 proof. Strip the SSZ container header so + // lean-multisig sees raw bytes. + let Ok(merged_bytes) = signed_block.merged_proof_bytes() else { + debug!("Reaggregation skipped: block proof envelope unusable"); + return Vec::new(); + }; + let split_bytes = match ethlambda_crypto::split_type_2_by_message( + merged_bytes, + pubkeys_per_component.clone(), + &data_root, + ) { + Ok(bytes) => bytes, + Err(err) => { + debug!(%err, data_root = %ethlambda_types::ShortRoot(&data_root.0), + "Reaggregation split failed"); + continue; + } + }; + let block_t1 = + TypeOneMultiSignature::new(att.aggregation_bits.clone(), split_bytes.clone()); + + // Step 2: merge the split with local partials covering the same + // AttestationData so the combined proof binds every known signer. + // A child-only merge needs ≥ 2 children; if we only have the + // block proof, use it as-is. + let combined = if candidate.local_partials.is_empty() { + block_t1 + } else { + let mut children: Vec<(Vec, _)> = + Vec::with_capacity(1 + candidate.local_partials.len()); + + // First child: the split-from-block proof, paired with the + // pubkeys derived from the block attestation's participant set. + let block_att_pubkeys = pubkeys_per_component[candidate.idx].clone(); + children.push((block_att_pubkeys, split_bytes)); + + // Remaining children: local partial Type-1s for the same data. + let mut bad = false; + for partial in &candidate.local_partials { + let mut pubkeys = Vec::with_capacity(partial.participants.count_ones()); + for vid in partial.participant_indices() { + if vid >= num_validators { + bad = true; + break; + } + match validators[vid as usize].get_attestation_pubkey() { + Ok(pk) => pubkeys.push(pk), + Err(_) => { + bad = true; + break; + } + } + } + if bad { + break; + } + children.push((pubkeys, partial.proof.clone())); + } + if bad { + continue; + } + + let merged_bytes = + match ethlambda_crypto::aggregate_proofs(children, &data_root, slot_u32) { + Ok(bytes) => bytes, + Err(err) => { + debug!(%err, data_root = %ethlambda_types::ShortRoot(&data_root.0), + "Reaggregation merge failed"); + continue; + } + }; + // Union the block participants with every local partial's + // participants — the merged proof binds them all. + let mut union_indices: HashSet = + validator_indices(&att.aggregation_bits).collect(); + for partial in &candidate.local_partials { + union_indices.extend(partial.participant_indices()); + } + let max_vid = union_indices.iter().copied().max().unwrap_or(0); + let mut union_bits = + ethlambda_types::attestation::AggregationBits::with_length(max_vid as usize + 1) + .expect("union bitfield length within capacity"); + for vid in &union_indices { + union_bits + .set(*vid as usize, true) + .expect("vid within union bitfield length"); + } + TypeOneMultiSignature::new(union_bits, merged_bytes) + }; + + let hashed = HashedAttestationData::new(att.data.clone()); + store_inserts.push((hashed.clone(), combined.clone())); + aggregates.push(SignedAggregatedAttestation { + data: att.data.clone(), + proof: combined, + }); + } + + // Insert into the new pool. `PayloadBuffer::push` auto-prunes any local + // partial whose participants are a strict subset of the combined proof, + // so explicit supersede tracking isn't needed. + if !store_inserts.is_empty() { + store.insert_new_aggregated_payloads_batch(store_inserts); + } + + aggregates +} + +struct Candidate { + idx: usize, + data_root: H256, + new_validators: usize, + local_partials: Vec, +} + +/// Identify attestations from a freshly imported block worth SNARK-splitting. +/// +/// A candidate is an attestation whose target outruns the store's justified +/// checkpoint and whose participants extend the local coverage for that +/// AttestationData. Candidates are sorted by uncovered-validator count and +/// capped at [`MAX_REAGGREGATIONS_PER_BLOCK`] so an attacker-shaped block +/// cannot blow past the slot budget. +fn select_candidates(store: &Store, attestations: &[AggregatedAttestation]) -> Vec { + let justified_slot = store.latest_justified().slot; + let mut candidates: Vec = Vec::new(); + for (idx, att) in attestations.iter().enumerate() { + if att.data.target.slot <= justified_slot { + continue; + } + let data_root = att.data.hash_tree_root(); + let (new, known) = store.existing_proofs_for_data(&data_root); + let mut local_union: HashSet = HashSet::new(); + for proof in new.iter().chain(known.iter()) { + local_union.extend(proof.participant_indices()); + } + let block_participants: HashSet = validator_indices(&att.aggregation_bits).collect(); + if block_participants.is_subset(&local_union) { + continue; + } + let mut local: Vec = new; + local.extend(known); + candidates.push(Candidate { + idx, + data_root, + new_validators: block_participants.difference(&local_union).count(), + local_partials: local, + }); + } + candidates.sort_by_key(|c| std::cmp::Reverse(c.new_validators)); + candidates.truncate(MAX_REAGGREGATIONS_PER_BLOCK); + candidates +} + +#[cfg(test)] +mod tests { + use super::*; + use ethlambda_storage::{Store, backend::InMemoryBackend}; + use ethlambda_types::{ + attestation::{AggregatedAttestation, AggregationBits, AttestationData}, + checkpoint::Checkpoint, + state::State, + }; + use std::sync::Arc; + + fn bits(indices: &[usize]) -> AggregationBits { + let max = indices.iter().copied().max().unwrap_or(0); + let mut b = AggregationBits::with_length(max + 1).unwrap(); + for &i in indices { + b.set(i, true).unwrap(); + } + b + } + + fn make_att(slot: u64, target_slot: u64, voters: &[usize]) -> AggregatedAttestation { + AggregatedAttestation { + aggregation_bits: bits(voters), + data: AttestationData { + slot, + head: Checkpoint::default(), + target: Checkpoint { + root: H256::ZERO, + slot: target_slot, + }, + source: Checkpoint::default(), + }, + } + } + + fn empty_store() -> Store { + let backend: Arc = Arc::new(InMemoryBackend::new()); + Store::from_anchor_state(backend, State::from_genesis(0, vec![])) + } + + #[test] + fn select_skips_target_at_or_below_justified() { + let mut store = empty_store(); + // Justified at slot 5; an attestation with target.slot = 5 must be skipped. + store.update_checkpoints(ethlambda_storage::ForkCheckpoints::new( + store.head(), + Some(Checkpoint { + root: H256::ZERO, + slot: 5, + }), + None, + )); + let candidates = select_candidates(&store, &[make_att(6, 5, &[0, 1])]); + assert!(candidates.is_empty()); + } + + #[test] + fn select_skips_when_block_participants_already_covered() { + let mut store = empty_store(); + let att = make_att(2, 2, &[0, 1]); + let hashed = HashedAttestationData::new(att.data.clone()); + // Seed the new-payload pool with a Type-1 covering validators {0, 1}. + store.insert_new_aggregated_payload(hashed, TypeOneMultiSignature::empty(bits(&[0, 1]))); + let candidates = select_candidates(&store, &[att]); + assert!(candidates.is_empty()); + } + + #[test] + fn select_keeps_attestation_with_new_voters() { + let mut store = empty_store(); + let att = make_att(2, 2, &[0, 1, 2]); + let hashed = HashedAttestationData::new(att.data.clone()); + // Local pool only covers validator 0. + store.insert_new_aggregated_payload(hashed, TypeOneMultiSignature::empty(bits(&[0]))); + let candidates = select_candidates(&store, &[att]); + assert_eq!(candidates.len(), 1); + // 1 and 2 are uncovered, so new_validators = 2. + assert_eq!(candidates[0].new_validators, 2); + assert_eq!(candidates[0].idx, 0); + } + + #[test] + fn select_caps_at_max_reaggregations_per_block() { + let store = empty_store(); + // Synthesize MAX + 5 attestations, each carrying a unique data root and + // distinct voters so none of the de-dup filters short-circuit. + let attestations: Vec = (0..MAX_REAGGREGATIONS_PER_BLOCK + 5) + .map(|i| make_att((i + 1) as u64, (i + 2) as u64, &[i * 2, i * 2 + 1])) + .collect(); + let candidates = select_candidates(&store, &attestations); + assert_eq!(candidates.len(), MAX_REAGGREGATIONS_PER_BLOCK); + } + + #[test] + fn select_prioritises_attestations_with_most_uncovered_voters() { + let store = empty_store(); + // Two attestations; one covers 1 new voter, the other covers 3. + let high = make_att(1, 2, &[0, 1, 2]); + let low = make_att(3, 4, &[5]); + let candidates = select_candidates(&store, &[low.clone(), high.clone()]); + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].new_validators, 3); + assert_eq!(candidates[0].idx, 1); + } +} diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 9bad04de..9007e8fd 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -12,16 +12,12 @@ use ethlambda_types::{ AggregatedAttestation, AggregationBits, Attestation, AttestationData, HashedAttestationData, SignedAggregatedAttestation, SignedAttestation, validator_indices, }, - block::{ - AggregatedAttestations, Block, BlockBody, ByteListMiB, BytecodeClaim, SignedBlock, - TypeOneInfo, TypeOneMultiSignature, TypeTwoMultiSignature, - }, + block::{AggregatedAttestations, Block, BlockBody, SignedBlock, TypeOneMultiSignature}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, - signature::ValidatorSignature, + signature::{ValidatorPublicKey, ValidatorSignature}, state::State, }; -use libssz::SszDecode as _; use tracing::{info, trace, warn}; use crate::{ @@ -386,7 +382,7 @@ pub fn on_gossip_aggregated_attestation( .map_err(StoreError::AggregateVerificationFailed)?; // Read stats before moving the proof into the store. - let num_participants = aggregated.proof.info.participants.count_ones(); + let num_participants = aggregated.proof.participants.count_ones(); let target_slot = aggregated.data.target.slot; let target_root = aggregated.data.target.root; let source_slot = aggregated.data.source.slot; @@ -515,36 +511,20 @@ fn on_block_core( // Process block body attestations and feed them into the payload buffer // so fork choice's LMD GHOST overlay can see block-only votes. // - // 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. + // Per-attestation participant bitfields come straight from + // `block.body.attestations[i].aggregation_bits`. Standalone Type-1 + // proof bytes are not recoverable from a block at import time; + // downstream re-aggregation has to come from the gossip channel or be + // recovered by SNARK-splitting `signed_block.proof` via + // `split_type_2_by_message`. The entries inserted here are info-only, + // used only for fork-choice vote bookkeeping. let aggregated_attestations = &block.body.attestations; - let merged = TypeTwoMultiSignature::from_ssz_bytes(signed_block.proof.iter().as_slice()) - .map_err(|_| StoreError::ProposerSignatureDecodingFailed)?; - let expected_info_len = aggregated_attestations.len() + 1; - if merged.info.len() != expected_info_len { - return Err(StoreError::AttestationSignatureMismatch { - signatures: merged.info.len(), - attestations: aggregated_attestations.len(), - }); - } let mut known_entries: Vec<(HashedAttestationData, TypeOneMultiSignature)> = Vec::with_capacity(aggregated_attestations.len()); - for (att, info) in aggregated_attestations - .iter() - .zip(merged.info.iter().take(aggregated_attestations.len())) - { + for att in aggregated_attestations.iter() { let hashed = HashedAttestationData::new(att.data.clone()); - let type_one = TypeOneMultiSignature { - info: info.clone(), - proof: ByteListMiB::default(), - }; + let type_one = TypeOneMultiSignature::empty(att.aggregation_bits.clone()); known_entries.push((hashed, type_one)); // Count each participating validator as a valid attestation. let count = validator_indices(&att.aggregation_bits).count() as u64; @@ -790,11 +770,8 @@ pub enum StoreError { #[error("Validator signature verification failed")] SignatureVerificationFailed, - #[error("Proposer signature could not be decoded")] - ProposerSignatureDecodingFailed, - - #[error("Proposer signature verification failed")] - ProposerSignatureVerificationFailed, + #[error("Block slot {0} exceeds u32 range")] + SlotOutOfRange(u64), #[error("State transition failed: {0}")] StateTransitionFailed(#[from] ethlambda_state_transition::Error), @@ -840,17 +817,6 @@ pub enum StoreError { store_time: u64, }, - #[error( - "Attestations and signatures don't match in length: got {signatures} signatures and {attestations} attestations" - )] - AttestationSignatureMismatch { - signatures: usize, - attestations: usize, - }, - - #[error("Aggregated proof participants don't match attestation aggregation bits")] - ParticipantsMismatch, - #[error("Aggregated signature verification failed: {0}")] AggregateVerificationFailed(ethlambda_crypto::VerificationError), @@ -981,15 +947,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, @@ -1007,9 +965,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. @@ -1054,7 +1012,7 @@ fn extend_proofs_greedily( .collect(); let att = AggregatedAttestation { - aggregation_bits: proof.info.participants.clone(), + aggregation_bits: proof.participants.clone(), data: att_data.clone(), }; @@ -1300,16 +1258,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 @@ -1324,63 +1279,92 @@ pub fn verify_block_signatures( let block = &signed_block.message; let attestations = &block.body.attestations; - let merged = TypeTwoMultiSignature::from_ssz_bytes(signed_block.proof.iter().as_slice()) - .map_err(|_| StoreError::ProposerSignatureDecodingFailed)?; - - let expected_info_len = attestations.len() + 1; - if merged.info.len() != expected_info_len { - return Err(StoreError::AttestationSignatureMismatch { - signatures: merged.info.len(), - attestations: attestations.len(), - }); - } - let validators = &state.validators; let num_validators = validators.len() as u64; - // Per-attestation entries: messages, slots, and participants must mirror - // the block body. The crypto binding for each is already checked at gossip. - for (attestation, info) in attestations.iter().zip(merged.info.iter()) { - if attestation.aggregation_bits != info.participants { - return Err(StoreError::ParticipantsMismatch); - } - if info.slot != attestation.data.slot { - return Err(StoreError::AttestationSignatureMismatch { - signatures: merged.info.len(), - attestations: attestations.len(), - }); - } - if info.message != attestation.data.hash_tree_root() { - return Err(StoreError::ParticipantsMismatch); - } + // Bounds-check participants before paying for the SNARK verifier. + // Per-component pubkeys are resolved from the block body itself; the + // wire proof carries no separate participant declaration to cross-check + // against (leanSpec PR #717). + for attestation in attestations.iter() { for vid in validator_indices(&attestation.aggregation_bits) { if vid >= num_validators { return Err(StoreError::InvalidValidatorIndex); } } } - - // Trailing proposer entry: single bit for `block.proposer_index`, - // message equals the block root, slot matches the block slot. - let proposer_info = &merged.info[attestations.len()]; - let block_root = block.hash_tree_root(); - if proposer_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); - } if block.proposer_index >= num_validators { return Err(StoreError::InvalidValidatorIndex); } + let block_root = block.hash_tree_root(); + let structural_elapsed = total_start.elapsed(); + + // Resolve pubkeys per Type-2 component for verify_type_2 and rederive the + // expected (message, slot) bindings from the block body. Attestation + // components use each participant's attestation_pubkey; the trailing + // proposer component uses the proposal_pubkey of `block.proposer_index`. + let expected_components = attestations.len() + 1; + let mut pubkeys_per_component: Vec> = + Vec::with_capacity(expected_components); + let mut expected_bindings: Vec<(H256, u32)> = Vec::with_capacity(expected_components); + + for attestation in attestations.iter() { + let mut pubkeys = Vec::new(); + for vid in validator_indices(&attestation.aggregation_bits) { + let validator = validators + .get(vid as usize) + .ok_or(StoreError::InvalidValidatorIndex)?; + let pk = validator + .get_attestation_pubkey() + .map_err(|_| StoreError::PubkeyDecodingFailed(vid))?; + pubkeys.push(pk); + } + pubkeys_per_component.push(pubkeys); + let slot_u32 = u32::try_from(attestation.data.slot) + .map_err(|_| StoreError::SlotOutOfRange(attestation.data.slot))?; + expected_bindings.push((attestation.data.hash_tree_root(), slot_u32)); + } + + let proposer_validator = validators + .get(block.proposer_index as usize) + .ok_or(StoreError::InvalidValidatorIndex)?; + let proposer_pubkey = proposer_validator + .get_proposal_pubkey() + .map_err(|_| StoreError::PubkeyDecodingFailed(block.proposer_index))?; + pubkeys_per_component.push(vec![proposer_pubkey]); + let block_slot_u32 = + u32::try_from(block.slot).map_err(|_| StoreError::SlotOutOfRange(block.slot))?; + expected_bindings.push((block_root, block_slot_u32)); + + // Strip the thin SSZ container wrapper to recover the raw lean-multisig + // Type-2 bytes the verifier consumes. The spec carries + // `signed_block.proof = [4-byte offset = 4][type2_wire]` so other clients + // can decode through the spec's `TypeTwoMultiSignature` SSZ container + // (leanSpec PR #717). + let merged_bytes = signed_block.merged_proof_bytes().map_err(|_| { + StoreError::AggregateVerificationFailed( + ethlambda_crypto::VerificationError::DeserializationFailed, + ) + })?; + + let crypto_start = std::time::Instant::now(); + ethlambda_crypto::verify_type_2_signature( + merged_bytes, + 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(()) @@ -1437,96 +1421,22 @@ mod tests { use super::*; use ethlambda_types::{ attestation::{AggregatedAttestation, AggregationBits, AttestationData}, - block::{ - BlockBody, ByteListMiB, SignedBlock, TypeOneMultiSignature, TypeTwoMultiSignature, - }, + block::{BlockBody, ByteList512KiB, SignedBlock, TypeOneMultiSignature}, checkpoint::Checkpoint, state::State, }; - use libssz::SszEncode as _; - /// Test helper: wrap a list of Type-1 attestation proofs plus a stub - /// proposer Type-1 into the SSZ-encoded merged Type-2 blob that the - /// post-Phase-3 `SignedBlock.proof` carries. + /// Test helper: placeholder block proof bytes. + /// + /// In production the merged proof is the raw `compress_without_pubkeys()` + /// output of `merge_many_type_1`, which can only be built by the + /// lean-multisig prover. Tests that don't go through + /// `verify_block_signatures` use an empty blob. fn make_signed_block_proof( - proposer_index: u64, - 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") - } - - #[test] - fn verify_signatures_rejects_participants_mismatch() { - // One validator in state so the proposer-index bounds check passes. - let state = State::from_genesis( - 1000, - vec![ethlambda_types::state::Validator { - attestation_pubkey: [0u8; 52], - proposal_pubkey: [0u8; 52], - index: 0, - }], - ); - - let attestation_data = AttestationData { - slot: 0, - head: Checkpoint::default(), - target: Checkpoint::default(), - source: Checkpoint::default(), - }; - - // Attestation declares bits [0, 1] in the block body... - let mut attestation_bits = AggregationBits::with_length(4).unwrap(); - attestation_bits.set(0, true).unwrap(); - attestation_bits.set(1, true).unwrap(); - - // ...but the merged Type-2 carries info[0].participants = [0, 1, 2]. - let mut proof_bits = AggregationBits::with_length(4).unwrap(); - proof_bits.set(0, true).unwrap(); - proof_bits.set(1, true).unwrap(); - proof_bits.set(2, true).unwrap(); - - let attestation = AggregatedAttestation { - aggregation_bits: attestation_bits, - data: attestation_data.clone(), - }; - let attestations = AggregatedAttestations::try_from(vec![attestation]).unwrap(); - - let block = Block { - slot: 0, - proposer_index: 0, - parent_root: H256::ZERO, - state_root: H256::ZERO, - body: BlockBody { attestations }, - }; - let block_root = block.hash_tree_root(); - - let mismatching_t1 = TypeOneMultiSignature::empty( - proof_bits, - attestation_data.hash_tree_root(), - attestation_data.slot, - ); - let proof = make_signed_block_proof(0, block_root, 0, vec![mismatching_t1]); - - let signed_block = SignedBlock { - message: block, - proof, - }; - - let result = verify_block_signatures(&state, &signed_block); - assert!( - matches!(result, Err(StoreError::ParticipantsMismatch)), - "Expected ParticipantsMismatch, got: {result:?}" - ); + _proposer_index: u64, + _attestation_proofs: Vec, + ) -> ByteList512KiB { + ByteList512KiB::default() } /// Regression test for https://github.com/lambdaclass/ethlambda/issues/259 @@ -1546,7 +1456,10 @@ 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 + // Each pool entry carries a fake Type-1 proof of this size. Realistic + // lean-multisig devnet5 Type-1 SNARKs weigh in around 200-400 KiB; we + // stay well under the 512 KiB cap so try_from never rejects. + const PROOF_SIZE: usize = 50 * 1024; const NUM_VALIDATORS: usize = 50; const NUM_PAYLOAD_ENTRIES: usize = 50; @@ -1639,8 +1552,8 @@ mod tests { bits.set(validator_id, true).unwrap(); let proof_bytes: Vec = vec![0xAB; PROOF_SIZE]; - let proof_data = SszList::try_from(proof_bytes).expect("proof fits in ByteListMiB"); - let proof = TypeOneMultiSignature::new(bits, data_root, att_data.slot, proof_data); + let proof_data = SszList::try_from(proof_bytes).expect("proof fits in ByteList512KiB"); + let proof = TypeOneMultiSignature::new(bits, proof_data); aggregated_payloads.insert(data_root, (att_data, vec![proof])); } @@ -1664,9 +1577,12 @@ mod tests { "MAX_ATTESTATIONS_DATA should cap attestations: got {attestation_count}" ); - // 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); + // Substitute a worst-case-size proof to model what `propose_block` + // would attach. The actual SNARK can't be built without lean-multisig, + // but the size cap (`ByteList512KiB`) bounds the worst case. + let _ = signatures; + let proof = + ByteList512KiB::try_from(vec![0xAB; 512 * 1024]).expect("worst-case proof fits in cap"); let signed_block = SignedBlock { message: block, proof, @@ -1777,7 +1693,7 @@ mod tests { bits.set(i, true).unwrap(); } let proof_data = SszList::try_from(vec![0xAB; 64]).unwrap(); - let proof = TypeOneMultiSignature::new(bits, data_root, att_data.slot, proof_data); + let proof = TypeOneMultiSignature::new(bits, proof_data); let mut aggregated_payloads = HashMap::new(); aggregated_payloads.insert(data_root, (att_data.clone(), vec![proof])); @@ -1833,10 +1749,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] @@ -1966,13 +1882,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 { @@ -1997,7 +1912,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() { @@ -2029,7 +1944,7 @@ mod tests { // Attestation bits mirror the proof's participants for each entry. for (att, proof) in &selected { - assert_eq!(att.aggregation_bits, proof.info.participants); + assert_eq!(att.aggregation_bits, proof.participants); assert_eq!(att.data, data); } } diff --git a/crates/blockchain/tests/forkchoice_spectests.rs b/crates/blockchain/tests/forkchoice_spectests.rs index 4e63a3dc..d0bd9f82 100644 --- a/crates/blockchain/tests/forkchoice_spectests.rs +++ b/crates/blockchain/tests/forkchoice_spectests.rs @@ -142,16 +142,12 @@ fn run(path: &Path) -> datatest_stable::Result<()> { let proof_fixture = att_data .proof .expect("gossipAggregatedAttestation step missing proof"); - let proof_bytes: Vec = proof_fixture.proof_data.into(); + let proof_bytes: Vec = proof_fixture.proof.into(); let proof_data = ByteList::try_from(proof_bytes) - .expect("aggregated proof data fits in ByteListMiB"); + .expect("aggregated proof data fits in ByteList512KiB"); 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/blockchain/tests/signature_spectests.rs b/crates/blockchain/tests/signature_spectests.rs index ac4867c4..a11a58cc 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..222f0e07 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -1,17 +1,23 @@ use std::sync::Once; use ethlambda_types::{ - block::ByteListMiB, + block::ByteList512KiB, primitives::H256, 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(); @@ -41,8 +47,26 @@ 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), + + #[error("component count ({components}) does not match pubkey-set count ({pubkey_sets})")] + ComponentPubkeyMismatch { + components: usize, + pubkey_sets: 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), } /// Error type for signature verification operations. @@ -56,39 +80,86 @@ 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: &ByteList512KiB, + 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(); + ByteList512KiB::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(); + ByteList512KiB::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 `ByteList512KiB` for the on-wire SSZ proof field. pub fn aggregate_signatures( public_keys: Vec, signatures: Vec, message: &H256, slot: u32, -) -> Result { +) -> Result { if public_keys.len() != signatures.len() { return Err(AggregationError::CountMismatch( public_keys.len(), signatures.len(), )); } - - // Handle empty input if public_keys.is_empty() { return Err(AggregationError::EmptyInput); } @@ -101,57 +172,43 @@ 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)>, + children: Vec<(Vec, ByteList512KiB)>, raw_public_keys: Vec, raw_signatures: Vec, message: &H256, slot: u32, -) -> Result { +) -> Result { if raw_public_keys.len() != raw_signatures.len() { return Err(AggregationError::CountMismatch( raw_public_keys.len(), 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,114 +216,208 @@ 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)>, + children: Vec<(Vec, ByteList512KiB)>, message: &H256, slot: u32, -) -> Result { +) -> Result { if children.len() < 2 { return Err(AggregationError::InsufficientChildren(children.len())); } 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::>()?; + + let proof = aggregate_type_1(&children_native, vec![], message.0, slot, LOG_INV_RATE) + .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; -/// 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)) + 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 -/// -/// * `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 +/// Verify a Type-1 aggregated signature proof. /// -/// # Returns +/// Cryptographically verifies that every `public_key` signed `message` at `slot`. /// -/// `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, + proof_data: &ByteList512KiB, public_keys: Vec, message: &H256, slot: u32, ) -> 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, ByteList512KiB)>, +) -> 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: &[u8], + 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, 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 bound to `message`. Generates a fresh SNARK; expensive. +/// +/// 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: &[u8], + pubkeys_per_component: Vec>, + message: &H256, +) -> Result { + ensure_prover_ready(); + + let pubkeys_per_info: Vec> = pubkeys_per_component + .into_iter() + .map(into_lean_pubkeys) + .collect(); + + let type_2 = LMType2::decompress_without_pubkeys(proof_data, pubkeys_per_info) + .ok_or(AggregationError::DeserializationFailed)?; + + 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()))?; + + 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 +559,43 @@ 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.iter().as_slice(), + vec![vec![pk_a.clone()], vec![pk_b.clone()]], + &[(msg_a, slot_a), (msg_b, slot_b)], + ) + .expect("verify type-2"); + + let split = split_type_2_by_message( + merged.iter().as_slice(), + 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..c31387ad 100644 --- a/crates/common/test-fixtures/src/fork_choice.rs +++ b/crates/common/test-fixtures/src/fork_choice.rs @@ -8,11 +8,8 @@ use crate::{ deser_xmss_hex, }; use ethlambda_types::attestation::XmssSignature; -use ethlambda_types::block::{ - ByteListMiB, MAX_ATTESTATIONS_DATA, SignedBlock, TypeOneMultiSignature, TypeTwoMultiSignature, -}; -use ethlambda_types::primitives::{H256, HashTreeRoot as _}; -use libssz::SszEncode as _; +use ethlambda_types::block::{ByteList512KiB, SignedBlock}; +use ethlambda_types::primitives::H256; use serde::{Deserialize, Deserializer}; use std::collections::HashMap; use std::path::Path; @@ -96,11 +93,16 @@ pub struct AttestationStepData { pub proof: Option, } +/// Aggregated-attestation proof carried by `gossipAggregatedAttestation` +/// steps (leanSpec PR #717 schema). +/// +/// `participants` arrives as `{ data: [bool, ...] }` and `proof` as +/// `{ data: "0x" }`; the latter is the lean-multisig Type-1 +/// `compress_without_pubkeys()` bytes for that AttestationData. #[derive(Debug, Clone, Deserialize)] pub struct ProofStepData { pub participants: AggregationBits, - #[serde(rename = "proofData")] - pub proof_data: HexByteList, + pub proof: HexByteList, } /// Hex-encoded byte list in the fixture format: `{ "data": "0xdeadbeef" }`. @@ -151,52 +153,17 @@ impl BlockStepData { } } - /// Build a `SignedBlock` whose merged Type-2 proof is structurally correct - /// (one Type-1 info entry per block-body attestation plus a trailing - /// proposer entry) but carries empty proof bytes — the crypto layer is - /// never invoked by callers of this helper. + /// Build a `SignedBlock` with an empty proof blob. /// /// Used by callers that import the block via `on_block_without_verification` - /// (fork-choice spec-test runner and Hive test-driver), where - /// `process_new_block` still decodes the merged proof and asserts the info - /// list aligns with `attestations.len() + 1` before dispatching. - /// - /// Oversized-block tests (more than `MAX_ATTESTATIONS_DATA` attestations) - /// overflow `TypeOneInfos`'s SSZ-list cap, so we fall back to an empty - /// proof blob — `process_new_block` rejects with `TooManyAttestationData` - /// before the proof is ever decoded, so its contents don't matter for - /// those scenarios. + /// (fork-choice spec-test runner and Hive test-driver), which skip the + /// crypto verifier entirely. Under the leanSpec PR #717 wire format the + /// merged proof bytes live opaquely on `SignedBlock.proof` and are only + /// inspected by `verify_block_signatures`, so an empty blob suffices. pub fn to_blank_signed_block(&self) -> SignedBlock { - let block = self.to_block(); - let block_root = block.hash_tree_root(); - let proof = if block.body.attestations.len() > MAX_ATTESTATIONS_DATA { - ByteListMiB::default() - } else { - let attestation_proofs: Vec = block - .body - .attestations - .iter() - .map(|att| { - TypeOneMultiSignature::empty( - att.aggregation_bits.clone(), - att.data.hash_tree_root(), - att.data.slot, - ) - }) - .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") - }; SignedBlock { - message: block, - proof, + message: self.to_block(), + proof: ByteList512KiB::default(), } } } diff --git a/crates/common/test-fixtures/src/verify_signatures.rs b/crates/common/test-fixtures/src/verify_signatures.rs index d9a44f28..f374c590 100644 --- a/crates/common/test-fixtures/src/verify_signatures.rs +++ b/crates/common/test-fixtures/src/verify_signatures.rs @@ -1,22 +1,23 @@ -//! Signature-verification test fixture types. +//! Signature-verification test fixture types (leanSpec PR #717 schema). //! //! Used both by the offline spec-test runner and the Hive -//! `/lean/v0/test_driver/verify_signatures/run` endpoint, which receives the +//! `/lean/v0/test_driver/verify_signatures/run` endpoint, which receive the //! same JSON shapes from the lean spec-assets simulator. +//! +//! Fixture shape after PR #717: +//! +//! signedBlock: +//! block: {...standard block fields...} +//! proof: { data: "0x" } -use crate::{AggregationBits, Block, Container, TestInfo, TestState, deser_xmss_hex}; -use ethlambda_types::attestation::{AggregationBits as EthAggregationBits, XmssSignature}; -use ethlambda_types::block::{ - ByteListMiB, SignedBlock, TypeOneMultiSignature, TypeTwoMultiSignature, -}; -use ethlambda_types::primitives::HashTreeRoot as _; -use libssz::SszEncode as _; +use crate::{Block, TestInfo, TestState}; +use ethlambda_types::block::{ByteList512KiB, SignedBlock}; use serde::Deserialize; use std::collections::HashMap; use std::fmt; use std::path::Path; -/// Root struct for verify signatures test vectors +/// Root struct for verify signatures test vectors. #[derive(Debug, Clone, Deserialize)] pub struct VerifySignaturesTestVector { #[serde(flatten)] @@ -24,7 +25,6 @@ pub struct VerifySignaturesTestVector { } impl VerifySignaturesTestVector { - /// Load a verify signatures test vector from a JSON file pub fn from_file>(path: P) -> Result> { let content = std::fs::read_to_string(path)?; let test_vector = serde_json::from_str(&content)?; @@ -32,7 +32,7 @@ impl VerifySignaturesTestVector { } } -/// A single verify signatures test case +/// A single verify-signatures test case. #[derive(Debug, Clone, Deserialize)] pub struct VerifySignaturesTest { #[allow(dead_code)] @@ -51,183 +51,73 @@ pub struct VerifySignaturesTest { pub info: TestInfo, } -// ============================================================================ -// Signed Block Types -// ============================================================================ - -/// Signed block with signature bundle (devnet4: no proposer attestation wrapper) +/// Fixture-side signed block: a block plus its raw merged Type-2 proof bytes. #[derive(Debug, Clone, Deserialize)] pub struct TestSignedBlock { #[serde(alias = "message")] pub block: Block, - pub signature: TestSignatureBundle, + pub proof: HexBytes, } -/// Lossy fixture-to-SignedBlock conversion: per-attestation proof bytes from -/// the fixture are dropped, leaving empty payloads. The merged Type-2 proof -/// preserves the per-attestation metadata (`message`, `slot`, `participants`) -/// and the proposer's XMSS signature so structural verification passes. -/// Adequate for callers that don't reach the leanVM aggregate verifier (e.g. -/// signature spec tests whose fixtures all set `expectException`). For real -/// signature verification use [`TestSignedBlock::try_into_signed_block_with_proofs`]. -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"); - - let attestation_t1s: Vec = value - .signature - .attestation_signatures - .data - .into_iter() - .zip(block.body.attestations.iter()) - .map(|(att_sig, att)| { - let participants: EthAggregationBits = att_sig.participants.into(); - TypeOneMultiSignature::empty(participants, att.data.hash_tree_root(), att.data.slot) - }) - .collect(); - - let mut all = attestation_t1s; - 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()) - .expect("merged Type-2 proof fits in ByteListMiB"); +/// `{ "data": "0x..." }` wrapper used by leanSpec fixtures for byte fields. +#[derive(Debug, Clone, Deserialize)] +pub struct HexBytes { + pub data: String, +} - SignedBlock { - message: block, - proof, - } +impl HexBytes { + pub fn decode(&self) -> Result, hex::FromHexError> { + let s = self.data.strip_prefix("0x").unwrap_or(&self.data); + hex::decode(s) } } /// Error returned by [`TestSignedBlock::try_into_signed_block_with_proofs`]. #[derive(Debug)] pub enum SignedBlockConvertError { - InvalidProofHex { index: usize, reason: String }, - ProofTooLarge { index: usize, len: usize }, - TooManyAttestationSignatures, + InvalidProofHex(String), + ProofTooLarge(usize), } impl fmt::Display for SignedBlockConvertError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidProofHex { index, reason } => { - write!( - f, - "attestation_signatures[{index}].proofData: invalid hex: {reason}" - ) - } - Self::ProofTooLarge { index, len } => { - write!( - f, - "attestation_signatures[{index}].proofData: {len} bytes exceeds ByteListMiB limit" - ) - } - Self::TooManyAttestationSignatures => { - f.write_str("attestation_signatures list exceeds AttestationSignatures limit") - } + Self::InvalidProofHex(reason) => write!(f, "proof.data hex decode failed: {reason}"), + Self::ProofTooLarge(len) => write!(f, "proof bytes exceed cap: {len}"), } } } impl std::error::Error for SignedBlockConvertError {} +/// Lossy fixture-to-SignedBlock conversion that preserves the merged proof. +/// +/// The conversion is fallible because the proof bytes may not decode as hex +/// or may exceed the wire cap. Tests with `expectException` set tolerate +/// failures upstream; the From impl panics so test runners get a clear +/// signal when fixture shape drifts. +impl From for SignedBlock { + fn from(value: TestSignedBlock) -> Self { + value + .try_into_signed_block_with_proofs() + .expect("fixture proof decode") + } +} + impl TestSignedBlock { - /// Materialize a `SignedBlock` that preserves the fixture-supplied - /// per-attestation proof bytes verbatim by folding every Type-1 plus the - /// proposer Type-1 into the block's merged Type-2 proof. The lossy - /// [`From`] impl above drops these bytes — use this one when the consumer - /// needs the original aggregate bytes (e.g. the Hive test-driver feeds - /// them through `verify_block_signatures`). + /// Materialize a `SignedBlock` preserving the fixture-supplied merged + /// Type-2 proof bytes verbatim. 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"); - - let attestation_t1s: Vec = self - .signature - .attestation_signatures - .data - .into_iter() - .zip(block.body.attestations.iter()) - .enumerate() - .map(|(index, (att_sig, att))| { - let participants: EthAggregationBits = att_sig.participants.into(); - let raw = &att_sig.proof_data.data; - let stripped = raw.strip_prefix("0x").unwrap_or(raw); - let bytes = hex::decode(stripped).map_err(|err| { - SignedBlockConvertError::InvalidProofHex { - index, - reason: err.to_string(), - } - })?; - let len = bytes.len(); - let proof_data = ByteListMiB::try_from(bytes) - .map_err(|_| SignedBlockConvertError::ProofTooLarge { index, len })?; - Ok(TypeOneMultiSignature::new( - participants, - att.data.hash_tree_root(), - att.data.slot, - proof_data, - )) - }) - .collect::>()?; - - if attestation_t1s.len() >= 17 { - return Err(SignedBlockConvertError::TooManyAttestationSignatures); - } - - let mut all = attestation_t1s; - all.push(TypeOneMultiSignature::for_proposer( - block.proposer_index, - proposer_proof, - block_root, - block.slot, - )); - let merged = TypeTwoMultiSignature::from_type_1s(all); - let proof = ByteListMiB::try_from(merged.to_ssz()) - .expect("merged Type-2 proof fits in ByteListMiB"); - + let bytes = self + .proof + .decode() + .map_err(|err| SignedBlockConvertError::InvalidProofHex(err.to_string()))?; + let len = bytes.len(); + let proof = ByteList512KiB::try_from(bytes) + .map_err(|_| SignedBlockConvertError::ProofTooLarge(len))?; Ok(SignedBlock { - message: block, + message: self.block.into(), proof, }) } } - -// ============================================================================ -// Signature Types -// ============================================================================ - -/// Bundle of signatures for block and attestations -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct TestSignatureBundle { - #[serde(rename = "proposerSignature", deserialize_with = "deser_xmss_hex")] - pub proposer_signature: XmssSignature, - #[serde(rename = "attestationSignatures")] - pub attestation_signatures: Container, -} - -/// Attestation signature from a validator -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct AttestationSignature { - pub participants: AggregationBits, - #[serde(rename = "proofData")] - pub proof_data: ProofData, -} - -/// Placeholder for future SNARK proof data -#[derive(Debug, Clone, Deserialize)] -pub struct ProofData { - pub data: String, -} diff --git a/crates/common/types/src/attestation.rs b/crates/common/types/src/attestation.rs index 12d8f7b5..4b2206e3 100644 --- a/crates/common/types/src/attestation.rs +++ b/crates/common/types/src/attestation.rs @@ -150,7 +150,7 @@ pub fn bits_is_subset(a: &AggregationBits, b: &AggregationBits) -> bool { /// /// The `proof` carries a Type-1 single-message multi-signer aggregate: the /// signed message is the attestation data root, participants live in -/// `proof.info.participants`, and the raw aggregate bytes are in `proof.proof`. +/// `proof.participants`, and the raw aggregate bytes are in `proof.proof`. #[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] pub struct SignedAggregatedAttestation { pub data: AttestationData, diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index c5db7ad5..3b5f0d83 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -11,13 +11,14 @@ use crate::{ // Convenience trait for calling hash_tree_root() without a hasher argument use primitives::HashTreeRoot as _; -/// Envelope carrying a block and a single merged proof binding every signature -/// it depends on. +/// Envelope carrying a block and the single merged proof binding every +/// signature it depends on. /// -/// The `proof` blob is the SSZ-encoded form of a [`TypeTwoMultiSignature`] that -/// covers, in order, every per-attestation Type-1 proof plus a singleton Type-1 -/// proof carrying the proposer's signature over the block root. Decode with -/// `TypeTwoMultiSignature::from_ssz_bytes(&signed_block.proof)`. +/// `proof` holds the SSZ-encoded form of a [`TypeTwoMultiSignature`] +/// container whose only field is a `ByteList512KiB` holding the raw +/// `compress_without_pubkeys()` Type-2 merged proof bytes. On the wire the +/// container collapses to `[4-byte offset = 4][type2_wire]` — a thin +/// 4-byte prefix in front of the lean-multisig bytes (leanSpec PR #717). /// ///
/// @@ -32,10 +33,67 @@ pub struct SignedBlock { /// The block being signed. pub message: Block, - /// SSZ-encoded merged proof for every signature this block depends on. - pub proof: ByteListMiB, + /// SSZ-encoded `TypeTwoMultiSignature` envelope. Use + /// [`SignedBlock::merged_proof_bytes`] to extract the raw + /// lean-multisig Type-2 bytes inside, or + /// [`SignedBlock::wrap_merged_proof`] when building an envelope from + /// the prover output. + pub proof: ByteList512KiB, } +impl SignedBlock { + /// Strip the SSZ-container offset header to return the raw + /// lean-multisig Type-2 merged proof bytes the verifier consumes. + pub fn merged_proof_bytes(&self) -> Result<&[u8], ProofEnvelopeError> { + let bytes = self.proof.iter().as_slice(); + if bytes.len() < 4 { + return Err(ProofEnvelopeError::TruncatedEnvelope); + } + let mut header = [0u8; 4]; + header.copy_from_slice(&bytes[..4]); + let offset = u32::from_le_bytes(header) as usize; + if offset != 4 { + return Err(ProofEnvelopeError::UnexpectedOffset(offset)); + } + Ok(&bytes[4..]) + } + + /// Wrap raw lean-multisig Type-2 bytes into a `SignedBlock.proof` + /// envelope: prepend the 4-byte SSZ offset header so the wire matches + /// the spec's `TypeTwoMultiSignature { proof: ByteList512KiB }` + /// container. + pub fn wrap_merged_proof(type2_wire: &[u8]) -> Result { + let mut wrapped = Vec::with_capacity(4 + type2_wire.len()); + wrapped.extend_from_slice(&4u32.to_le_bytes()); + wrapped.extend_from_slice(type2_wire); + let len = wrapped.len(); + ByteList512KiB::try_from(wrapped).map_err(|_| ProofEnvelopeError::ExceedsCap(len)) + } +} + +/// Errors returned by the [`SignedBlock`] proof-envelope helpers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProofEnvelopeError { + /// Envelope is shorter than the 4-byte SSZ offset header. + TruncatedEnvelope, + /// Offset header is not the expected single-field value `4`. + UnexpectedOffset(usize), + /// Wrapped proof would exceed `ByteList512KiB`'s cap. + ExceedsCap(usize), +} + +impl core::fmt::Display for ProofEnvelopeError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::TruncatedEnvelope => f.write_str("block proof envelope truncated"), + Self::UnexpectedOffset(o) => write!(f, "block proof envelope offset {o}, expected 4"), + Self::ExceedsCap(n) => write!(f, "wrapped proof {n} bytes exceeds 512 KiB cap"), + } + } +} + +impl std::error::Error for ProofEnvelopeError {} + // Manual Debug impl because the merged proof bytes are large and opaque. impl core::fmt::Debug for SignedBlock { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { @@ -46,146 +104,87 @@ impl core::fmt::Debug for SignedBlock { } } -pub type ByteListMiB = ByteList<1_048_576>; +/// 512 KiB byte-list cap shared by every block-level / Type-1 proof field. +/// Matches leanSpec PR #717's `ByteList512KiB` SSZ container. +pub type ByteList512KiB = ByteList<524_288>; // ============================================================================ -// Type-1 / Type-2 multi-signature model +// Type-1 multi-signature // ============================================================================ - -/// Trusted `Evaluation` field carried inside Type-1 / Type-2 proofs. -/// -/// Upstream models this as a `Bytes32` placeholder until `lean_multisig_py` -/// bindings land with the concrete SSZ serialisation. Mirrored here as `H256`. -pub type BytecodeClaim = H256; - -/// Per-message metadata for a Type-1 (single-message) multi-signer proof. -/// -/// Carries everything a verifier needs to recompute the proof's binding inputs -/// without re-deriving from block content. Participants stay in bitfield form -/// for wire compactness; pubkeys are resolved at the binding boundary from the -/// validator registry. -#[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] -pub struct TypeOneInfo { - /// The 32-byte message that was signed - /// (e.g. `hash_tree_root` of attestation data, or a block root). - pub message: H256, - /// The slot in which the signatures were created. - pub slot: u64, - /// Bitfield indicating which validators contributed signatures. - pub participants: AggregationBits, - /// Trusted evaluation tied to the proof. Recomputed by the verifier when - /// received externally. - pub bytecode_claim: BytecodeClaim, -} +// +// Wire format mirrors leanSpec PR #717: `TypeOneMultiSignature` is a flat +// `{ participants, proof }` pair. The signed `message` and `slot` are NOT +// carried on the envelope — verifiers rederive each component's binding +// from the surrounding block body (attestation `data` + slot for body +// components, block root + slot for the proposer component). +// +// `TypeTwoMultiSignature` has no Rust-side struct: the block carries the +// raw lean-multisig Type-2 bytes directly on `SignedBlock.proof`. Component +// participant bitfields come from `block.body.attestations[i].aggregation_bits` +// (and `block.proposer_index` for the trailing proposer entry). /// Maximum number of distinct `AttestationData` entries permitted in a single /// block. Canonical home for the cap shared across `ethlambda-blockchain`, /// `ethlambda-test-fixtures`, and the wire types in this crate. /// -/// See: leanSpec commit 0c9528a (PR #536). -pub const MAX_ATTESTATIONS_DATA: usize = 16; - -/// SSZ-list of Type-1 info entries packed inside a Type-2 proof. -/// -/// Holds at most `MAX_ATTESTATIONS_DATA` distinct attestation entries plus one -/// for the proposer's own signature. Mirrors upstream -/// `TypeOneInfos.LIMIT = MAX_ATTESTATIONS_DATA + 1`. -pub type TypeOneInfos = SszList; +/// See: leanSpec PR #717, which lowered the cap from 16 to 8 alongside the +/// merged block-proof refactor. +pub const MAX_ATTESTATIONS_DATA: usize = 8; /// A Type-1 single-message proof aggregating signatures from many validators. -#[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] -pub struct TypeOneMultiSignature { - /// Message, slot, participants, and trusted bytecode claim. - pub info: TypeOneInfo, - /// Raw aggregated proof bytes (`ExecutionProof` on the Rust side). - pub proof: ByteListMiB, -} - -/// A Type-2 merged proof covering many distinct messages. /// -/// On the wire a `SignedBlock` will carry the SSZ-serialised form of this -/// container as its single proof blob (introduced in a later phase). The -/// block-level info list enumerates every `(message, slot, participants)` -/// tuple the proof binds to. +/// Used: +/// - as a gossip-level `SignedAggregatedAttestation.proof`, +/// - as an in-memory entry in the aggregated payload pool, +/// - as one of the components fed into `merge_type_1s_into_type_2` when +/// building a block proof. +/// +/// `participants` and `proof` are independent fields: the proof bytes are +/// the lean-multisig `compress_without_pubkeys()` form; `participants` is +/// the bitfield identifying which validators are bound by the proof. The +/// verifier resolves pubkeys from `participants` at verify time. #[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] -pub struct TypeTwoMultiSignature { - /// Per-message metadata, one entry per merged Type-1 proof. - pub info: TypeOneInfos, - /// Aggregation-level trusted evaluation. Recomputed on receive. - pub bytecode_claim: BytecodeClaim, - /// Raw merged proof bytes (`ExecutionProof` on the Rust side). - pub proof: ByteListMiB, +pub struct TypeOneMultiSignature { + /// Bitfield identifying validators bound by this proof. + pub participants: AggregationBits, + /// Aggregated proof bytes in lean-multisig compact (no-pubkeys) form. + pub proof: ByteList512KiB, } impl TypeOneMultiSignature { - /// Build a Type-1 proof 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 participants and proof bytes. + pub fn new(participants: AggregationBits, proof: ByteList512KiB) -> Self { Self { - info: TypeOneInfo { - message, - slot, - participants, - bytecode_claim: BytecodeClaim::ZERO, - }, - proof: proof_data, + participants, + proof, } } - /// 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: ByteList512KiB) -> 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. pub fn participant_indices(&self) -> impl Iterator + '_ { - validator_indices(&self.info.participants) - } -} - -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. - pub fn from_type_1s(type_1s: Vec) -> Self { - let infos: Vec = type_1s.into_iter().map(|t1| t1.info).collect(); - let info = TypeOneInfos::try_from(infos) - .expect("type-1 infos within MAX_ATTESTATIONS_DATA + 1 limit"); - Self { - info, - bytecode_claim: BytecodeClaim::ZERO, - proof: ByteListMiB::default(), - } + validator_indices(&self.participants) } } @@ -302,79 +301,39 @@ mod tests { b } - fn sample_type_one_info() -> TypeOneInfo { - TypeOneInfo { - message: H256([7u8; 32]), - slot: 42, - participants: sample_bits(8, &[0, 3, 7]), - bytecode_claim: H256([1u8; 32]), - } - } - - #[test] - fn type_one_info_ssz_round_trip() { - let info = sample_type_one_info(); - let bytes = info.to_ssz(); - let decoded = TypeOneInfo::from_ssz_bytes(&bytes).expect("decode"); - assert_eq!(decoded.message, info.message); - assert_eq!(decoded.slot, info.slot); - assert_eq!(decoded.bytecode_claim, info.bytecode_claim); - assert_eq!( - decoded.participants.as_bytes(), - info.participants.as_bytes() - ); - } - #[test] fn type_one_multi_signature_ssz_round_trip() { let proof_bytes: Vec = (0..64).collect(); let sig = TypeOneMultiSignature { - info: sample_type_one_info(), - proof: ByteListMiB::try_from(proof_bytes.clone()).unwrap(), + participants: sample_bits(8, &[0, 3, 7]), + proof: ByteList512KiB::try_from(proof_bytes.clone()).unwrap(), }; let bytes = sig.to_ssz(); let decoded = TypeOneMultiSignature::from_ssz_bytes(&bytes).expect("decode"); assert_eq!(decoded.proof.to_vec(), proof_bytes); - assert_eq!(decoded.info.slot, sig.info.slot); + assert_eq!(decoded.participants.as_bytes(), sig.participants.as_bytes()); } #[test] - fn type_two_multi_signature_ssz_round_trip() { - let infos: Vec = (0..3) - .map(|i| TypeOneInfo { - message: H256([i as u8; 32]), - slot: 100 + i as u64, - participants: sample_bits(8, &[i, i + 1]), - bytecode_claim: H256([0xAA; 32]), - }) - .collect(); - let merged_bytes: Vec = (0..128).map(|i| (i % 256) as u8).collect(); - let sig = TypeTwoMultiSignature { - info: TypeOneInfos::try_from(infos.clone()).unwrap(), - bytecode_claim: H256([0xBB; 32]), - proof: ByteListMiB::try_from(merged_bytes.clone()).unwrap(), + fn signed_block_ssz_round_trip_empty_proof() { + let block = Block { + slot: 7, + proposer_index: 3, + parent_root: H256::ZERO, + state_root: H256::ZERO, + body: BlockBody::default(), }; - let bytes = sig.to_ssz(); - let decoded = TypeTwoMultiSignature::from_ssz_bytes(&bytes).expect("decode"); - assert_eq!(decoded.info.len(), 3); - assert_eq!(decoded.proof.to_vec(), merged_bytes); - assert_eq!(decoded.bytecode_claim, sig.bytecode_claim); - for (got, want) in decoded.info.iter().zip(infos.iter()) { - assert_eq!(got.slot, want.slot); - assert_eq!(got.message, want.message); - } - } - - #[test] - fn type_one_infos_respects_limit() { - let too_many: Vec = (0..18) - .map(|i| TypeOneInfo { - message: H256([i as u8; 32]), - slot: i as u64, - participants: sample_bits(1, &[0]), - bytecode_claim: H256([0u8; 32]), - }) - .collect(); - assert!(TypeOneInfos::try_from(too_many).is_err()); + let signed = SignedBlock { + message: block, + proof: ByteList512KiB::default(), + }; + let bytes = signed.to_ssz(); + let decoded = SignedBlock::from_ssz_bytes(&bytes).expect("decode"); + assert_eq!(decoded.proof.len(), 0); + assert_eq!(decoded.message.slot, signed.message.slot); + assert_eq!( + decoded.message.proposer_index, + signed.message.proposer_index + ); } } diff --git a/crates/common/types/tests/ssz_types.rs b/crates/common/types/tests/ssz_types.rs index d5395b29..c825eaa7 100644 --- a/crates/common/types/tests/ssz_types.rs +++ b/crates/common/types/tests/ssz_types.rs @@ -126,7 +126,7 @@ impl From for DomainSignedAttestation { // NOTE: After Phase 3 the legacy `BlockSignatures` / `AttestationSignatures` / // `AggregatedSignatureProof` containers are removed from the domain, and -// `SignedBlock` now carries a single `proof: ByteListMiB` field. The pinned +// `SignedBlock` now carries a single `proof: ByteList512KiB` field. The pinned // leanSpec fixtures still use the old shape, so SSZ-byte and root assertions // for `SignedBlock`, `BlockSignatures`, `AggregatedSignatureProof`, and // `SignedAggregatedAttestation` are intentionally skipped in diff --git a/crates/net/p2p/src/req_resp/handlers.rs b/crates/net/p2p/src/req_resp/handlers.rs index b8ead618..75fd1068 100644 --- a/crates/net/p2p/src/req_resp/handlers.rs +++ b/crates/net/p2p/src/req_resp/handlers.rs @@ -400,7 +400,7 @@ mod tests { use super::*; use ethlambda_storage::{ForkCheckpoints, backend::InMemoryBackend}; use ethlambda_types::{ - block::{Block, BlockBody, ByteListMiB}, + block::{Block, BlockBody, ByteList512KiB}, state::State, }; use std::sync::Arc; @@ -414,7 +414,7 @@ mod tests { state_root: H256::ZERO, body: BlockBody::default(), }, - proof: ByteListMiB::default(), + proof: ByteList512KiB::default(), } } diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index e95bcb6d..67977b83 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -494,7 +494,7 @@ mod tests { #[tokio::test] async fn test_get_latest_finalized_block() { use ethlambda_types::{ - block::{Block, BlockBody, ByteListMiB, SignedBlock}, + block::{Block, BlockBody, ByteList512KiB, SignedBlock}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, }; @@ -515,7 +515,7 @@ mod tests { let block_root = block.header().hash_tree_root(); let signed_block = SignedBlock { message: block, - proof: ByteListMiB::default(), + proof: ByteList512KiB::default(), }; // Persist the signed block and mark it as the latest finalized checkpoint. @@ -555,7 +555,7 @@ mod tests { #[tokio::test] async fn test_get_latest_finalized_block_serves_genesis_with_placeholder_proof() { - use ethlambda_types::block::{ByteListMiB, SignedBlock}; + use ethlambda_types::block::{ByteList512KiB, SignedBlock}; use libssz::SszEncode; // Genesis-anchored store: `init_store` writes the header + state but no @@ -574,7 +574,7 @@ mod tests { .expect("genesis served via get_signed_block"); let expected = SignedBlock { message: genesis_block.message.clone(), - proof: ByteListMiB::default(), + proof: ByteList512KiB::default(), }; let expected_ssz = expected.to_ssz(); diff --git a/crates/net/rpc/src/test_driver.rs b/crates/net/rpc/src/test_driver.rs index 7ceb30ae..1a19f4f4 100644 --- a/crates/net/rpc/src/test_driver.rs +++ b/crates/net/rpc/src/test_driver.rs @@ -42,9 +42,9 @@ use ethlambda_types::{ attestation::{ AggregationBits as EthAggregationBits, SignedAggregatedAttestation, SignedAttestation, }, - block::{Block, ByteListMiB, TypeOneMultiSignature}, + block::{Block, ByteList512KiB, TypeOneMultiSignature}, checkpoint::Checkpoint, - primitives::{H256, HashTreeRoot}, + primitives::H256, state::{State, anchor_pair_is_consistent}, }; use serde::{Deserialize, Serialize}; @@ -386,17 +386,12 @@ fn apply_step(store: &mut Store, step: ForkChoiceStep) -> Result<(), String> { .proof .ok_or_else(|| "gossipAggregatedAttestation step missing proof".to_string())?; let participants: EthAggregationBits = proof.participants.into(); - let proof_bytes: Vec = proof.proof_data.into(); - let proof_data = ByteListMiB::try_from(proof_bytes) + let proof_bytes: Vec = proof.proof.into(); + let proof_data = ByteList512KiB::try_from(proof_bytes) .map_err(|err| format!("aggregated proof data too large: {err:?}"))?; let data: ethlambda_types::attestation::AttestationData = att.data.into(); 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/net/rpc/tests/test_driver_e2e.rs b/crates/net/rpc/tests/test_driver_e2e.rs index 687ccf31..5051741e 100644 --- a/crates/net/rpc/tests/test_driver_e2e.rs +++ b/crates/net/rpc/tests/test_driver_e2e.rs @@ -249,6 +249,10 @@ async fn verify_signatures_with_empty_validator_set_fails_cleanly() { // proposer (no validators in the set). The driver should return // succeeded:false with a descriptive error, matching the simulator's // expectException path. + // + // The proof blob is empty (`0x`): the verifier rejects the proposer-index + // bound before reaching the SNARK decode, so the bytes content doesn't + // matter for this test. let signed_block = json!({ "message": { "slot": 1, @@ -257,10 +261,7 @@ async fn verify_signatures_with_empty_validator_set_fails_cleanly() { "stateRoot": ZERO_ROOT, "body": {"attestations": {"data": []}}, }, - "signature": { - "proposerSignature": "0x".to_string() + &"00".repeat(ethlambda_types::signature::SIGNATURE_SIZE), - "attestationSignatures": {"data": []}, - }, + "proof": {"data": "0x"}, }); let body = json!({ diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 41cf6843..e1d6a380 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -5,7 +5,7 @@ use crate::api::{StorageBackend, StorageWriteBatch, Table}; use ethlambda_types::{ attestation::{AttestationData, HashedAttestationData, bits_is_subset}, - block::{Block, BlockBody, BlockHeader, ByteListMiB, SignedBlock, TypeOneMultiSignature}, + block::{Block, BlockBody, BlockHeader, ByteList512KiB, SignedBlock, TypeOneMultiSignature}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, signature::ValidatorSignature, @@ -154,12 +154,12 @@ impl PayloadBuffer { let mut to_remove: Vec = Vec::new(); for (i, p) in entry.proofs.iter().enumerate() { // Incoming is subsumed by an existing proof (incl. equal). Skip. - if bits_is_subset(&proof.info.participants, &p.info.participants) { + if bits_is_subset(&proof.participants, &p.participants) { return; } // Existing is a strict subset of incoming. Mark for removal. // (Non-strict equality was ruled out by the check above.) - if bits_is_subset(&p.info.participants, &proof.info.participants) { + if bits_is_subset(&p.participants, &proof.participants) { to_remove.push(i); } } @@ -1015,12 +1015,12 @@ impl Store { let proof = match view.get(Table::BlockSignatures, &key).expect("get") { Some(proof_bytes) => { - ByteListMiB::from_ssz_bytes(&proof_bytes).expect("valid block proof") + ByteList512KiB::from_ssz_bytes(&proof_bytes).expect("valid block proof") } // Synthesis only covers the genesis-style anchor (slot 0). Any other // missing-proof case is a storage corruption that should surface // as `None` rather than fabricating a block with an empty proof. - None if header.slot == 0 => ByteListMiB::default(), + None if header.slot == 0 => ByteList512KiB::default(), None => return None, }; @@ -1680,7 +1680,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). @@ -1688,7 +1688,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`. @@ -1699,7 +1699,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 { @@ -2345,7 +2345,7 @@ mod tests { .expect("genesis block must be retrievable with synthetic proof"); assert_eq!(signed.message.slot, 0); - assert_eq!(signed.proof, ByteListMiB::default()); + assert_eq!(signed.proof, ByteList512KiB::default()); } /// The synthesis branch must be confined to the slot-0 anchor: a