diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96feb01..637c299 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - name: Setup Rust uses: dtolnay/rust-toolchain@master @@ -45,30 +45,14 @@ jobs: name: Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - # - name: Download test fixtures - # env: - # GH_TOKEN: ${{ github.token }} - # run: | - # mkdir -p leanSpec/fixtures - # gh run download --repo leanEthereum/leanSpec --name fixtures-prod-scheme --dir leanSpec/fixtures - - - name: Checkout leanSpec - uses: actions/checkout@v6 - with: - repository: leanEthereum/leanSpec - ref: 050fa4a18881d54d7dc07601fe59e34eb20b9630 - path: leanSpec - - - name: Install uv - uses: astral-sh/setup-uv@v7 - - - name: Generate test fixtures - working-directory: leanSpec + - name: Download test fixtures + env: + GH_TOKEN: ${{ github.token }} run: | - uv sync - uv run fill --fork=devnet --clean -n auto + mkdir -p leanSpec/fixtures + gh run download --repo leanEthereum/leanSpec --name fixtures-prod-scheme --dir leanSpec/fixtures - name: Setup Rust uses: dtolnay/rust-toolchain@master diff --git a/Cargo.lock b/Cargo.lock index 2792c18..ccfeb54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3165,17 +3165,16 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "leansig" version = "0.1.0" -source = "git+https://github.com/leanEthereum/leansig.git?rev=f10dcbe#f10dcbefac2502d356d93f686e8b4ecd8dc8840a" +source = "git+https://github.com/leanEthereum/leansig.git?rev=ae12a5feb25d917c42b6466444ebd56ec115a629#ae12a5feb25d917c42b6466444ebd56ec115a629" dependencies = [ "dashmap", "ethereum_ssz", - "ethereum_ssz_derive", "num-bigint 0.4.6", "num-traits", - "p3-baby-bear 0.3.0", - "p3-field 0.3.0", + "p3-baby-bear 0.4.1", + "p3-field 0.4.1", "p3-koala-bear", - "p3-symmetric 0.3.0", + "p3-symmetric 0.4.1", "rand 0.9.2", "rayon", "serde", @@ -4413,17 +4412,31 @@ dependencies = [ [[package]] name = "p3-baby-bear" -version = "0.3.0" -source = "git+https://github.com/Plonky3/Plonky3.git?rev=a33a312#a33a31274a5e78bb5fbe3f82ffd2c294e17fa830" +version = "0.4.1" +source = "git+https://github.com/Plonky3/Plonky3.git?rev=d421e32#d421e32d3821174ae1f7e528d4bb92b7b18ab295" dependencies = [ - "p3-field 0.3.0", - "p3-mds 0.3.0", + "p3-challenger", + "p3-field 0.4.1", + "p3-mds 0.4.1", "p3-monty-31", - "p3-poseidon2 0.3.0", - "p3-symmetric 0.3.0", + "p3-poseidon2 0.4.1", + "p3-symmetric 0.4.1", "rand 0.9.2", ] +[[package]] +name = "p3-challenger" +version = "0.4.1" +source = "git+https://github.com/Plonky3/Plonky3.git?rev=d421e32#d421e32d3821174ae1f7e528d4bb92b7b18ab295" +dependencies = [ + "p3-field 0.4.1", + "p3-maybe-rayon 0.4.1", + "p3-monty-31", + "p3-symmetric 0.4.1", + "p3-util 0.4.1", + "tracing", +] + [[package]] name = "p3-dft" version = "0.2.3-succinct" @@ -4439,14 +4452,14 @@ dependencies = [ [[package]] name = "p3-dft" -version = "0.3.0" -source = "git+https://github.com/Plonky3/Plonky3.git?rev=a33a312#a33a31274a5e78bb5fbe3f82ffd2c294e17fa830" +version = "0.4.1" +source = "git+https://github.com/Plonky3/Plonky3.git?rev=d421e32#d421e32d3821174ae1f7e528d4bb92b7b18ab295" dependencies = [ "itertools 0.14.0", - "p3-field 0.3.0", - "p3-matrix 0.3.0", - "p3-maybe-rayon 0.3.0", - "p3-util 0.3.0", + "p3-field 0.4.1", + "p3-matrix 0.4.1", + "p3-maybe-rayon 0.4.1", + "p3-util 0.4.1", "spin 0.10.0", "tracing", ] @@ -4467,13 +4480,13 @@ dependencies = [ [[package]] name = "p3-field" -version = "0.3.0" -source = "git+https://github.com/Plonky3/Plonky3.git?rev=a33a312#a33a31274a5e78bb5fbe3f82ffd2c294e17fa830" +version = "0.4.1" +source = "git+https://github.com/Plonky3/Plonky3.git?rev=d421e32#d421e32d3821174ae1f7e528d4bb92b7b18ab295" dependencies = [ "itertools 0.14.0", "num-bigint 0.4.6", - "p3-maybe-rayon 0.3.0", - "p3-util 0.3.0", + "p3-maybe-rayon 0.4.1", + "p3-util 0.4.1", "paste", "rand 0.9.2", "serde", @@ -4482,13 +4495,14 @@ dependencies = [ [[package]] name = "p3-koala-bear" -version = "0.3.0" -source = "git+https://github.com/Plonky3/Plonky3.git?rev=a33a312#a33a31274a5e78bb5fbe3f82ffd2c294e17fa830" +version = "0.4.1" +source = "git+https://github.com/Plonky3/Plonky3.git?rev=d421e32#d421e32d3821174ae1f7e528d4bb92b7b18ab295" dependencies = [ - "p3-field 0.3.0", + "p3-challenger", + "p3-field 0.4.1", "p3-monty-31", - "p3-poseidon2 0.3.0", - "p3-symmetric 0.3.0", + "p3-poseidon2 0.4.1", + "p3-symmetric 0.4.1", "rand 0.9.2", ] @@ -4509,13 +4523,13 @@ dependencies = [ [[package]] name = "p3-matrix" -version = "0.3.0" -source = "git+https://github.com/Plonky3/Plonky3.git?rev=a33a312#a33a31274a5e78bb5fbe3f82ffd2c294e17fa830" +version = "0.4.1" +source = "git+https://github.com/Plonky3/Plonky3.git?rev=d421e32#d421e32d3821174ae1f7e528d4bb92b7b18ab295" dependencies = [ "itertools 0.14.0", - "p3-field 0.3.0", - "p3-maybe-rayon 0.3.0", - "p3-util 0.3.0", + "p3-field 0.4.1", + "p3-maybe-rayon 0.4.1", + "p3-util 0.4.1", "rand 0.9.2", "serde", "tracing", @@ -4530,8 +4544,8 @@ checksum = "c3968ad1160310296eb04f91a5f4edfa38fe1d6b2b8cd6b5c64e6f9b7370979e" [[package]] name = "p3-maybe-rayon" -version = "0.3.0" -source = "git+https://github.com/Plonky3/Plonky3.git?rev=a33a312#a33a31274a5e78bb5fbe3f82ffd2c294e17fa830" +version = "0.4.1" +source = "git+https://github.com/Plonky3/Plonky3.git?rev=d421e32#d421e32d3821174ae1f7e528d4bb92b7b18ab295" [[package]] name = "p3-mds" @@ -4550,31 +4564,31 @@ dependencies = [ [[package]] name = "p3-mds" -version = "0.3.0" -source = "git+https://github.com/Plonky3/Plonky3.git?rev=a33a312#a33a31274a5e78bb5fbe3f82ffd2c294e17fa830" +version = "0.4.1" +source = "git+https://github.com/Plonky3/Plonky3.git?rev=d421e32#d421e32d3821174ae1f7e528d4bb92b7b18ab295" dependencies = [ - "p3-dft 0.3.0", - "p3-field 0.3.0", - "p3-symmetric 0.3.0", - "p3-util 0.3.0", + "p3-dft 0.4.1", + "p3-field 0.4.1", + "p3-symmetric 0.4.1", + "p3-util 0.4.1", "rand 0.9.2", ] [[package]] name = "p3-monty-31" -version = "0.3.0" -source = "git+https://github.com/Plonky3/Plonky3.git?rev=a33a312#a33a31274a5e78bb5fbe3f82ffd2c294e17fa830" +version = "0.4.1" +source = "git+https://github.com/Plonky3/Plonky3.git?rev=d421e32#d421e32d3821174ae1f7e528d4bb92b7b18ab295" dependencies = [ "itertools 0.14.0", "num-bigint 0.4.6", - "p3-dft 0.3.0", - "p3-field 0.3.0", - "p3-matrix 0.3.0", - "p3-maybe-rayon 0.3.0", - "p3-mds 0.3.0", - "p3-poseidon2 0.3.0", - "p3-symmetric 0.3.0", - "p3-util 0.3.0", + "p3-dft 0.4.1", + "p3-field 0.4.1", + "p3-matrix 0.4.1", + "p3-maybe-rayon 0.4.1", + "p3-mds 0.4.1", + "p3-poseidon2 0.4.1", + "p3-symmetric 0.4.1", + "p3-util 0.4.1", "paste", "rand 0.9.2", "serde", @@ -4599,13 +4613,13 @@ dependencies = [ [[package]] name = "p3-poseidon2" -version = "0.3.0" -source = "git+https://github.com/Plonky3/Plonky3.git?rev=a33a312#a33a31274a5e78bb5fbe3f82ffd2c294e17fa830" +version = "0.4.1" +source = "git+https://github.com/Plonky3/Plonky3.git?rev=d421e32#d421e32d3821174ae1f7e528d4bb92b7b18ab295" dependencies = [ - "p3-field 0.3.0", - "p3-mds 0.3.0", - "p3-symmetric 0.3.0", - "p3-util 0.3.0", + "p3-field 0.4.1", + "p3-mds 0.4.1", + "p3-symmetric 0.4.1", + "p3-util 0.4.1", "rand 0.9.2", ] @@ -4622,11 +4636,11 @@ dependencies = [ [[package]] name = "p3-symmetric" -version = "0.3.0" -source = "git+https://github.com/Plonky3/Plonky3.git?rev=a33a312#a33a31274a5e78bb5fbe3f82ffd2c294e17fa830" +version = "0.4.1" +source = "git+https://github.com/Plonky3/Plonky3.git?rev=d421e32#d421e32d3821174ae1f7e528d4bb92b7b18ab295" dependencies = [ "itertools 0.14.0", - "p3-field 0.3.0", + "p3-field 0.4.1", "serde", ] @@ -4641,8 +4655,8 @@ dependencies = [ [[package]] name = "p3-util" -version = "0.3.0" -source = "git+https://github.com/Plonky3/Plonky3.git?rev=a33a312#a33a31274a5e78bb5fbe3f82ffd2c294e17fa830" +version = "0.4.1" +source = "git+https://github.com/Plonky3/Plonky3.git?rev=d421e32#d421e32d3821174ae1f7e528d4bb92b7b18ab295" dependencies = [ "serde", ] diff --git a/Cargo.toml b/Cargo.toml index f27fc36..8762fb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ clap = { version = "4.3", features = ["derive", "env"] } ethereum-types = { version = "0.15.1", features = ["serialize"] } # XMSS signatures -leansig = { git = "https://github.com/leanEthereum/leansig.git", rev = "f10dcbe" } +leansig = { git = "https://github.com/leanEthereum/leansig.git", rev = "ae12a5feb25d917c42b6466444ebd56ec115a629" } # SSZ deps # TODO: roll up our own implementation diff --git a/Makefile b/Makefile index c7f45ef..5f745cb 100644 --- a/Makefile +++ b/Makefile @@ -13,14 +13,14 @@ test: ## 🧪 Run all tests, then forkchoice tests with skip-signature-verificat docker-build: ## 🐳 Build the Docker image docker build -t ghcr.io/lambdaclass/ethlambda:local . -LEAN_SPEC_COMMIT_HASH:=050fa4a18881d54d7dc07601fe59e34eb20b9630 +LEAN_SPEC_COMMIT_HASH:=fbbacbea4545be870e25e3c00a90fc69e019c5bb 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 -o fixtures #--scheme=prod + cd leanSpec && uv run fill --fork devnet --scheme=prod -o fixtures # lean-quickstart: # git clone https://github.com/blockblaz/lean-quickstart.git --depth 1 --single-branch diff --git a/crates/blockchain/Cargo.toml b/crates/blockchain/Cargo.toml index 4244a3b..589abf9 100644 --- a/crates/blockchain/Cargo.toml +++ b/crates/blockchain/Cargo.toml @@ -45,3 +45,8 @@ name = "forkchoice_spectests" path = "tests/forkchoice_spectests.rs" harness = false required-features = ["skip-signature-verification"] + +[[test]] +name = "signature_spectests" +path = "tests/signature_spectests.rs" +harness = false diff --git a/crates/blockchain/src/key_manager.rs b/crates/blockchain/src/key_manager.rs index ca9bfbc..1f447bf 100644 --- a/crates/blockchain/src/key_manager.rs +++ b/crates/blockchain/src/key_manager.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use ethlambda_types::{ - attestation::{Attestation, XmssSignature}, + attestation::{AttestationData, XmssSignature}, primitives::{H256, TreeHash}, signature::{ValidatorSecretKey, ValidatorSignature}, }; @@ -51,29 +51,44 @@ impl KeyManager { self.keys.keys().copied().collect() } - /// Signs a message for the specified validator. + /// Signs an attestation for the specified validator. + /// + /// This method computes the message hash from the attestation data and signs it + /// using the validator's secret key. /// /// # Arguments /// /// * `validator_id` - The ID of the validator whose key should be used for signing - /// * `epoch` - The epoch number used in the XMSS signature scheme - /// * `message` - The message hash to sign (typically the hash tree root of AttestationData) + /// * `attestation_data` - The attestation data to sign /// /// # Returns /// /// Returns an `XmssSignature` (3112 bytes) on success, or a `KeyManagerError` if: /// - The validator ID is not found in the KeyManager /// - The signing operation fails + pub fn sign_attestation( + &mut self, + validator_id: u64, + attestation_data: &AttestationData, + ) -> Result { + let message_hash = attestation_data.tree_hash_root(); + let epoch = attestation_data.slot as u32; + self.sign_message(validator_id, epoch, &message_hash) + } + + /// Signs a message hash for the specified validator. /// - /// # Example + /// # Arguments /// - /// ```ignore - /// let signature = key_manager.sign_message( - /// validator_id, - /// epoch, - /// &message_hash - /// )?; - /// ``` + /// * `validator_id` - The ID of the validator whose key should be used for signing + /// * `epoch` - The epoch number used in the XMSS signature scheme + /// * `message` - The message hash to sign + /// + /// # Returns + /// + /// Returns an `XmssSignature` (3112 bytes) on success, or a `KeyManagerError` if: + /// - The validator ID is not found in the KeyManager + /// - The signing operation fails fn sign_message( &mut self, validator_id: u64, @@ -96,15 +111,6 @@ impl KeyManager { Ok(xmss_sig) } - - pub fn sign_attestation( - &mut self, - attestation: &Attestation, - ) -> Result { - let message_hash = attestation.tree_hash_root(); - let epoch = attestation.data.slot as u32; - self.sign_message(attestation.validator_id, epoch, &message_hash) - } } #[cfg(test)] @@ -119,7 +125,7 @@ mod tests { } #[test] - fn test_sign_message_validator_not_found() { + fn test_sign_attestation_validator_not_found() { let keys = HashMap::new(); let mut key_manager = KeyManager::new(keys); let message = H256::default(); diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index abbdacc..2ebef53 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -152,16 +152,14 @@ impl BlockChainServer { continue; } - // Hash the attestation data for signing - let attestation = Attestation { - data: attestation_data.clone(), - validator_id, - }; - // Sign the attestation - let Ok(signature) = self.key_manager.sign_attestation(&attestation).inspect_err( - |err| error!(%slot, %validator_id, %err, "Failed to sign attestation"), - ) else { + let Ok(signature) = self + .key_manager + .sign_attestation(validator_id, &attestation_data) + .inspect_err( + |err| error!(%slot, %validator_id, %err, "Failed to sign attestation"), + ) + else { continue; }; @@ -216,7 +214,7 @@ impl BlockChainServer { // Sign the proposer's attestation let Ok(proposer_signature) = self .key_manager - .sign_attestation(&proposer_attestation) + .sign_attestation(validator_id, &proposer_attestation.data) .inspect_err( |err| error!(%slot, %validator_id, %err, "Failed to sign proposer attestation"), ) @@ -224,21 +222,21 @@ impl BlockChainServer { return; }; - // Assemble flat signature list: [attestation_sig_0, ..., attestation_sig_n, proposer_sig] - let mut signatures = attestation_signatures; - signatures.push(proposer_signature); - let block_signatures: BlockSignatures = - signatures.try_into().expect("signatures within limit"); - // Assemble SignedBlockWithAttestation let signed_block = SignedBlockWithAttestation { message: BlockWithAttestation { block, proposer_attestation, }, - signature: block_signatures, + signature: BlockSignatures { + proposer_signature, + attestation_signatures: attestation_signatures + .try_into() + .expect("attestation signatures within limit"), + }, }; + // Process the block locally before publishing self.on_block(signed_block.clone()); // Publish to gossip network diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 4ec8aec..0b00228 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -4,8 +4,13 @@ use ethlambda_state_transition::{ is_proposer, process_block, process_slots, slot_is_justifiable_after, }; use ethlambda_types::{ - attestation::{Attestation, AttestationData, SignedAttestation, XmssSignature}, - block::{Attestations, Block, BlockBody, SignedBlockWithAttestation}, + attestation::{ + AggregatedAttestation, Attestation, AttestationData, SignedAttestation, XmssSignature, + }, + block::{ + AggregatedAttestations, AggregatedSignatureProof, AggregationBits, Block, BlockBody, + SignedBlockWithAttestation, + }, primitives::{H256, TreeHash}, state::{ChainConfig, Checkpoint, State}, }; @@ -97,11 +102,18 @@ pub struct Store { /// - Keyed by validator index to enforce one attestation per validator. latest_new_attestations: HashMap, - /// Per-validator XMSS signatures for attestations. + /// Per-validator XMSS signatures learned from gossip. /// /// Keyed by SignatureKey(validator_id, attestation_data_root). - /// Populated from both gossip network and blocks. - signatures: HashMap, + gossip_signatures: HashMap, + + /// Aggregated signature proofs learned from blocks. + /// - Keyed by SignatureKey(validator_id, attestation_data_root). + /// - Values are lists of AggregatedSignatureProof, each containing the participants + /// bitfield indicating which validators signed. + /// - Used for recursive signature aggregation when building blocks. + /// - Populated by on_block. + aggregated_payloads: HashMap>, } impl Store { @@ -148,7 +160,8 @@ impl Store { states, latest_known_attestations: HashMap::new(), latest_new_attestations: HashMap::new(), - signatures: HashMap::new(), + gossip_signatures: HashMap::new(), + aggregated_payloads: HashMap::new(), } } @@ -204,6 +217,7 @@ impl Store { .blocks .get(&data.target.root) .ok_or(StoreError::UnknownTargetBlock(data.target.root))?; + if !self.blocks.contains_key(&data.head.root) { return Err(StoreError::UnknownHeadBlock(data.head.root)); } @@ -306,7 +320,7 @@ impl Store { let validator_pubkey = target_state.validators[validator_id as usize] .get_pubkey() .map_err(|_| StoreError::PubkeyDecodingFailed(validator_id))?; - let message = attestation.tree_hash_root(); + let message = attestation.data.tree_hash_root(); #[cfg(not(feature = "skip-signature-verification"))] { use ethlambda_types::signature::ValidatorSignature; @@ -324,7 +338,7 @@ impl Store { // Store signature for later lookup during block building let signature_key = (validator_id, message); - self.signatures + self.gossip_signatures .insert(signature_key, signed_attestation.signature); Ok(()) } @@ -457,27 +471,36 @@ impl Store { self.states.insert(block_root, post_state); // Process block body attestations and their signatures - // Flat signature list: [attestation_sig_0, ..., attestation_sig_n, proposer_sig] - let attestations = &block.body.attestations; - let signatures = &signed_block.signature; + let aggregated_attestations = &block.body.attestations; + let attestation_signatures = &signed_block.signature.attestation_signatures; // Process block body attestations. // TODO: fail the block if an attestation is invalid. Right now we // just log a warning. - for (i, attestation) in attestations.iter().enumerate() { - let validator_id = attestation.validator_id; - let data_root = attestation.data.tree_hash_root(); + for (att, proof) in aggregated_attestations + .iter() + .zip(attestation_signatures.iter()) + { + let validator_ids = aggregation_bits_to_validator_indices(&att.aggregation_bits); + let data_root = att.data.tree_hash_root(); - // Store the signature for future block building (if available) - if let Some(sig) = signatures.get(i) { + for validator_id in validator_ids { + // Update Proof Map - Store the proof so future block builders can reuse this aggregation let key: SignatureKey = (validator_id, data_root); - self.signatures.insert(key, sig.clone()); - } - - // Update Fork Choice - Register the vote immediately (historical/on-chain) - // TODO: validate attestations before processing - if let Err(err) = self.on_attestation(attestation.clone(), true) { - warn!(%slot, %validator_id, %err, "Invalid attestation in block"); + self.aggregated_payloads + .entry(key) + .or_default() + .push(proof.clone()); + + // Update Fork Choice - Register the vote immediately (historical/on-chain) + let attestation = Attestation { + validator_id, + data: att.data.clone(), + }; + // TODO: validate attestations before processing + if let Err(err) = self.on_attestation(attestation, true) { + warn!(%slot, %validator_id, %err, "Invalid attestation in block"); + } } } @@ -491,15 +514,14 @@ impl Store { // It is treated as pending until interval 3 (end of slot). // Store the proposer's signature for potential future block building - // The proposer signature is the last element in the flat signature list let proposer_sig_key: SignatureKey = ( proposer_attestation.validator_id, proposer_attestation.data.tree_hash_root(), ); - if let Some(proposer_sig) = signed_block.signature.last() { - self.signatures - .insert(proposer_sig_key, proposer_sig.clone()); - } + self.gossip_signatures.insert( + proposer_sig_key, + signed_block.signature.proposer_signature.clone(), + ); // Process proposer attestation (enters "new" stage, not "known") // TODO: validate attestations before processing @@ -585,16 +607,15 @@ impl Store { self.head } - /// Produce a block and per-attestation signature payloads for the target slot. + /// Produce a block and per-aggregated-attestation signature payloads for the target slot. /// - /// Returns the finalized block and attestation signatures aligned - /// with `block.body.attestations`. The proposer signature is NOT included; - /// it will be appended by the caller after signing the proposer's attestation. + /// Returns the finalized block and attestation signature payloads aligned + /// with `block.body.attestations`. pub fn produce_block_with_signatures( &mut self, slot: u64, validator_index: u64, - ) -> Result<(Block, Vec), StoreError> { + ) -> Result<(Block, Vec), StoreError> { // Get parent block and state to build upon let head_root = self.get_proposal_head(slot); let head_state = self @@ -636,7 +657,8 @@ impl Store { head_root, &available_attestations, &known_block_roots, - &self.signatures, + &self.gossip_signatures, + &self.aggregated_payloads, )?; Ok((block, signatures)) @@ -757,6 +779,9 @@ pub enum StoreError { attestations: usize, }, + #[error("Aggregated proof participants don't match attestation aggregation bits")] + ParticipantsMismatch, + #[error("Missing target state for block: {0}")] MissingTargetState(H256), @@ -764,11 +789,59 @@ pub enum StoreError { NotProposer { validator_index: u64, slot: u64 }, } +/// Extract validator indices from aggregation bits. +fn aggregation_bits_to_validator_indices(bits: &AggregationBits) -> Vec { + bits.iter() + .enumerate() + .filter_map(|(i, bit)| if bit { Some(i as u64) } else { None }) + .collect() +} + +/// Group individual attestations by their data and create aggregated attestations. +/// +/// Attestations with identical `AttestationData` are combined into a single +/// `AggregatedAttestation` with a bitfield indicating participating validators. +fn aggregate_attestations_by_data(attestations: &[Attestation]) -> Vec { + // Group attestations by their data root + let mut groups: HashMap)> = HashMap::new(); + + for attestation in attestations { + let data_root = attestation.data.tree_hash_root(); + groups + .entry(data_root) + .or_insert_with(|| (attestation.data.clone(), Vec::new())) + .1 + .push(attestation.validator_id); + } + + // Convert groups into aggregated attestations + groups + .into_values() + .map(|(data, validator_ids)| { + // Find max validator id to determine bitlist capacity + let max_id = validator_ids.iter().copied().max().unwrap_or(0) as usize; + let mut bits = + AggregationBits::with_capacity(max_id + 1).expect("validator count exceeds limit"); + + for vid in validator_ids { + bits.set(vid as usize, true) + .expect("validator id exceeds capacity"); + } + + AggregatedAttestation { + aggregation_bits: bits, + data, + } + }) + .collect() +} + /// Build a valid block on top of this state. /// -/// Returns the block, post-state, and a flat list of attestation signatures +/// Returns the block, post-state, and a list of attestation signature proofs /// (one per attestation in block.body.attestations). The proposer signature -/// is NOT included; it is appended by the caller. +/// proof is NOT included; it is appended by the caller. +#[expect(clippy::too_many_arguments)] fn build_block( head_state: &State, slot: u64, @@ -776,8 +849,9 @@ fn build_block( parent_root: H256, available_attestations: &[Attestation], known_block_roots: &HashSet, - signatures: &HashMap, -) -> Result<(Block, State, Vec), StoreError> { + gossip_signatures: &HashMap, + _aggregated_payloads: &HashMap>, +) -> Result<(Block, State, Vec), StoreError> { // Start with empty attestation set let mut attestations: Vec = Vec::new(); @@ -785,8 +859,10 @@ fn build_block( let mut included_keys: HashSet = HashSet::new(); // Fixed-point loop: collect attestations until no new ones can be added - let (post_state, final_attestations) = loop { - let attestations_list: Attestations = attestations + let (post_state, aggregated_attestations) = loop { + // Aggregate attestations by data for the candidate block + let aggregated = aggregate_attestations_by_data(&attestations); + let aggregated_attestations: AggregatedAttestations = aggregated .clone() .try_into() .expect("attestation count exceeds limit"); @@ -798,7 +874,7 @@ fn build_block( parent_root, state_root: H256::ZERO, body: BlockBody { - attestations: attestations_list, + attestations: aggregated_attestations, }, }; @@ -830,7 +906,8 @@ fn build_block( } // Only include if we have a signature for this attestation - if signatures.contains_key(&sig_key) { + // TODO: consider aggregated payloads as well + if gossip_signatures.contains_key(&sig_key) { new_attestations.push(attestation.clone()); included_keys.insert(sig_key); } @@ -838,25 +915,27 @@ fn build_block( // Fixed point reached: no new attestations found if new_attestations.is_empty() { - break (post_state, attestations); + break (post_state, aggregated); } // Add new attestations and continue iteration attestations.extend(new_attestations); }; - // Compute flat signature list for attestations - let attestation_signatures: Vec = final_attestations + // Compute aggregated signature proofs for each aggregated attestation + let signatures: Vec = aggregated_attestations .iter() - .filter_map(|att| { - let data_root = att.data.tree_hash_root(); - let sig_key: SignatureKey = (att.validator_id, data_root); - signatures.get(&sig_key).cloned() + .map(|agg_att| { + // Use the attestation's aggregation bits as the participants bitfield. + // The proof_data would be populated by actual leanVM aggregation. + // For now, we create an empty proof as a placeholder. + // TODO: Implement actual signature aggregation via lean-multisig. + AggregatedSignatureProof::empty(agg_att.aggregation_bits.clone()) }) .collect(); // Build final block with correct state root - let final_attestations_list: Attestations = final_attestations + let final_aggregated: AggregatedAttestations = aggregated_attestations .try_into() .expect("attestation count exceeds limit"); @@ -866,17 +945,16 @@ fn build_block( parent_root, state_root: post_state.tree_hash_root(), body: BlockBody { - attestations: final_attestations_list, + attestations: final_aggregated, }, }; - Ok((final_block, post_state, attestation_signatures)) + Ok((final_block, post_state, signatures)) } /// Verify all signatures in a signed block. /// -/// The signature list follows the ordering: [attestation_sig_0, ..., attestation_sig_n, proposer_sig] -/// where signatures[i] corresponds to attestations[i] for i < n. +/// Each attestation has a corresponding proof in the signature list. #[cfg(not(feature = "skip-signature-verification"))] fn verify_signatures( state: &State, @@ -886,53 +964,60 @@ fn verify_signatures( let block = &signed_block.message.block; let attestations = &block.body.attestations; - let signatures = &signed_block.signature; + let attestation_signatures = &signed_block.signature.attestation_signatures; - // Signatures should be: n attestation signatures + 1 proposer signature - let expected_sig_count = attestations.len() + 1; - if signatures.len() != expected_sig_count { + if attestations.len() != attestation_signatures.len() { return Err(StoreError::AttestationSignatureMismatch { - signatures: signatures.len(), - attestations: expected_sig_count, + signatures: attestation_signatures.len(), + attestations: attestations.len(), }); } let validators = &state.validators; let num_validators = validators.len() as u64; - // Verify each attestation signature - for (i, attestation) in attestations.iter().enumerate() { - let validator_id = attestation.validator_id; - if validator_id >= num_validators { + // Verify each attestation's signature proof + for (attestation, aggregated_proof) in attestations.iter().zip(attestation_signatures) { + let validator_ids = aggregation_bits_to_validator_indices(&attestation.aggregation_bits); + if validator_ids.iter().any(|vid| *vid >= num_validators) { return Err(StoreError::InvalidValidatorIndex); } - let epoch: u32 = attestation.data.slot.try_into().expect("slot exceeds u32"); - let message = attestation.tree_hash_root(); + // Verify participants bitfield matches attestation aggregation bits + let proof_validator_ids = + aggregation_bits_to_validator_indices(aggregated_proof.participants()); + if validator_ids != proof_validator_ids { + return Err(StoreError::ParticipantsMismatch); + } - let validator = validators - .get(validator_id as usize) - .ok_or(StoreError::InvalidValidatorIndex)?; - let pubkey = validator - .get_pubkey() - .map_err(|_| StoreError::PubkeyDecodingFailed(validator.index))?; + let _epoch: u32 = attestation.data.slot.try_into().expect("slot exceeds u32"); + let _message = attestation.data.tree_hash_root(); - let signature = &signatures[i]; - let validator_signature = ValidatorSignature::from_bytes(signature) - .map_err(|_| StoreError::SignatureDecodingFailed)?; + // Collect public keys for all participating validators + let _public_keys: Vec<_> = validator_ids + .iter() + .map(|&vid| { + validators + .get(vid as usize) + .ok_or(StoreError::InvalidValidatorIndex) + .and_then(|v| { + v.get_pubkey() + .map_err(|_| StoreError::PubkeyDecodingFailed(v.index)) + }) + }) + .collect::>()?; - if !pubkey.is_valid(epoch, &message, &validator_signature) { - return Err(StoreError::SignatureVerificationFailed); - } + // TODO: Verify the aggregated proof using lean-multisig + // aggregated_proof.verify(&public_keys, &message, epoch)?; + // + // For now, we skip attestation signature verification. + // The proposer signature is still verified below. } - // Verify proposer signature (last element in signature list) let proposer_attestation = &signed_block.message.proposer_attestation; - let proposer_sig = signatures - .last() - .ok_or(StoreError::ProposerSignatureDecodingFailed)?; - let proposer_signature = ValidatorSignature::from_bytes(proposer_sig) - .map_err(|_| StoreError::ProposerSignatureDecodingFailed)?; + let proposer_signature = + ValidatorSignature::from_bytes(&signed_block.signature.proposer_signature) + .map_err(|_| StoreError::ProposerSignatureDecodingFailed)?; let proposer = validators .get(block.proposer_index as usize) @@ -942,12 +1027,12 @@ fn verify_signatures( .get_pubkey() .map_err(|_| StoreError::PubkeyDecodingFailed(proposer.index))?; - let epoch: u32 = proposer_attestation + let epoch = proposer_attestation .data .slot .try_into() .expect("slot exceeds u32"); - let message = proposer_attestation.tree_hash_root(); + let message = proposer_attestation.data.tree_hash_root(); if !proposer_pubkey.is_valid(epoch, &message, &proposer_signature) { return Err(StoreError::ProposerSignatureVerificationFailed); diff --git a/crates/blockchain/state_transition/src/justified_slots_ops.rs b/crates/blockchain/state_transition/src/justified_slots_ops.rs index e59f556..a89ffcf 100644 --- a/crates/blockchain/state_transition/src/justified_slots_ops.rs +++ b/crates/blockchain/state_transition/src/justified_slots_ops.rs @@ -1,29 +1,41 @@ -//! Helper functions for absolute-indexed JustifiedSlots operations. +//! Helper functions for relative-indexed JustifiedSlots operations. //! -//! The bitlist stores justification status using absolute slot indices: -//! - Index 0 = slot 0 -//! - Index N = slot N -//! -//! This matches the Python spec's representation for SSZ compatibility. +//! The bitlist stores justification status relative to the finalized boundary: +//! - Index 0 = finalized_slot + 1 +//! - Slots ≤ finalized_slot are implicitly justified (no storage needed) use ethlambda_types::state::JustifiedSlots; -/// Check if a slot is justified using absolute slot index. -pub fn is_slot_justified(slots: &JustifiedSlots, _finalized_slot: u64, target_slot: u64) -> bool { - slots.get(target_slot as usize).unwrap_or(false) +/// Calculate relative index for a slot after finalization. +/// Returns None if slot <= finalized_slot (implicitly justified). +fn relative_index(target_slot: u64, finalized_slot: u64) -> Option { + target_slot + .checked_sub(finalized_slot)? + .checked_sub(1) + .map(|idx| idx as usize) } -/// Mark a slot as justified using absolute slot index. -pub fn set_justified(slots: &mut JustifiedSlots, _finalized_slot: u64, target_slot: u64) { - slots - .set(target_slot as usize, true) - .expect("index out of bounds"); +/// Check if a slot is justified (finalized slots are implicitly justified). +pub fn is_slot_justified(slots: &JustifiedSlots, finalized_slot: u64, target_slot: u64) -> bool { + relative_index(target_slot, finalized_slot) + .map(|idx| slots.get(idx).unwrap_or(false)) + .unwrap_or(true) // Finalized slots are implicitly justified } -/// Extend capacity to cover slots up to and including target_slot. +/// Mark a slot as justified. No-op if slot is finalized. +pub fn set_justified(slots: &mut JustifiedSlots, finalized_slot: u64, target_slot: u64) { + if let Some(idx) = relative_index(target_slot, finalized_slot) { + slots.set(idx, true).expect("index out of bounds"); + } +} + +/// Extend capacity to cover slots up to target_slot relative to finalized boundary. /// New slots are initialized to false (unjustified). -pub fn extend_to_slot(slots: &mut JustifiedSlots, _finalized_slot: u64, target_slot: u64) { - let required_capacity = (target_slot + 1) as usize; +pub fn extend_to_slot(slots: &mut JustifiedSlots, finalized_slot: u64, target_slot: u64) { + let Some(required_idx) = relative_index(target_slot, finalized_slot) else { + return; + }; + let required_capacity = required_idx + 1; if slots.len() >= required_capacity { return; } @@ -35,7 +47,6 @@ pub fn extend_to_slot(slots: &mut JustifiedSlots, _finalized_slot: u64, target_s } /// Shift window by dropping finalized slots when finalization advances. -/// Note: This shifts absolute indices, removing slots 0..delta from the front. pub fn shift_window(slots: &mut JustifiedSlots, delta: usize) { if delta == 0 { return; diff --git a/crates/blockchain/state_transition/src/lib.rs b/crates/blockchain/state_transition/src/lib.rs index 1c66992..08fb7d8 100644 --- a/crates/blockchain/state_transition/src/lib.rs +++ b/crates/blockchain/state_transition/src/lib.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use ethlambda_types::{ - block::{Attestations, Block, BlockHeader}, + block::{AggregatedAttestations, Block, BlockHeader}, primitives::{H256, TreeHash}, state::{Checkpoint, JustificationValidators, State}, }; @@ -134,38 +134,16 @@ fn process_block_header(state: &mut State, block: &Block) -> Result<(), Error> { // Extend justified_slots to cover slots up to (block.slot - 1) // + // The storage is relative to the finalized boundary. // The current block's slot is not materialized until processing completes, - // so we only extend up to the last materialized slot (parent's slot). - let parent_slot = parent_header.slot; + // so we only extend up to the last materialized slot. + let last_materialized_slot = block.slot - 1; justified_slots_ops::extend_to_slot( &mut state.justified_slots, state.latest_finalized.slot, - parent_slot, + last_materialized_slot, ); - // Mark the genesis/parent slot as justified when processing the first block. - // This matches the Python spec's behavior which explicitly stores this bit. - if is_genesis_parent { - justified_slots_ops::set_justified( - &mut state.justified_slots, - state.latest_finalized.slot, - parent_slot, - ); - } - - // Extend for any empty slots between parent and this block - for _slot in (parent_slot + 1)..block.slot { - // Empty slots are not justified, but we need to extend the bitlist - // to maintain the correct length. The extend_to_slot function handles this. - } - if block.slot > parent_slot + 1 { - justified_slots_ops::extend_to_slot( - &mut state.justified_slots, - state.latest_finalized.slot, - block.slot - 1, - ); - } - let new_header = BlockHeader { slot: block.slot, proposer_index: block.proposer_index, @@ -199,7 +177,10 @@ pub fn is_proposer(validator_index: u64, slot: u64, num_validators: u64) -> bool /// Apply attestations and update justification/finalization /// according to the Lean Consensus 3SF-mini rules. -fn process_attestations(state: &mut State, attestations: &Attestations) -> Result<(), Error> { +fn process_attestations( + state: &mut State, + attestations: &AggregatedAttestations, +) -> Result<(), Error> { let validator_count = state.validators.len(); let mut justifications: HashMap> = state .justifications_roots @@ -219,17 +200,21 @@ fn process_attestations(state: &mut State, attestations: &Attestations) -> Resul // For is_justifiable_after checks (must use original value, not updated during iteration) let original_finalized_slot = state.latest_finalized.slot; - // Build root_to_slots mapping for justifications pruning. - // A root may appear at multiple slots (missed slots produce duplicate zero hashes). - let mut root_to_slots: HashMap> = HashMap::new(); + // Map roots to their latest slot for pruning. + // + // Votes for zero hash are ignored, so we only need the most recent slot + // where a root appears to decide whether it is still unfinalized. + let mut root_to_slot: HashMap = HashMap::new(); for slot in (state.latest_finalized.slot + 1)..state.historical_block_hashes.len() as u64 { if let Some(root) = state.historical_block_hashes.get(slot as usize) { - root_to_slots.entry(*root).or_default().push(slot); + root_to_slot + .entry(*root) + .and_modify(|x| *x = (*x).max(slot)) + .or_insert(slot); } } for attestation in attestations { - let validator_id = attestation.validator_id; let attestation_data = &attestation.data; let source = attestation_data.source; let target = attestation_data.target; @@ -253,6 +238,11 @@ fn process_attestations(state: &mut State, attestations: &Attestations) -> Resul continue; } + // Ignore votes that reference zero-hash slots. + if source.root == H256::ZERO || target.root == H256::ZERO { + continue; + } + // Ensure the vote refers to blocks that actually exist on our chain if !checkpoint_exists(state, source) || !checkpoint_exists(state, target) { continue; @@ -268,13 +258,18 @@ fn process_attestations(state: &mut State, attestations: &Attestations) -> Resul continue; } - // Record the vote for this individual attestation + // Record the vote let votes = justifications .entry(target.root) .or_insert_with(|| std::iter::repeat_n(false, validator_count).collect()); - // Mark that this validator has voted for the target - if (validator_id as usize) < validator_count { - votes[validator_id as usize] = true; + // Mark that each validator in this aggregation has voted for the target. + for (validator_id, _) in attestation + .aggregation_bits + .iter() + .enumerate() + .filter(|(_, voted)| *voted) + { + votes[validator_id] = true; } // Check whether the vote count crosses the supermajority threshold @@ -304,9 +299,8 @@ fn process_attestations(state: &mut State, attestations: &Attestations) -> Resul // Prune justifications whose roots only appear at now-finalized slots justifications.retain(|root, _| { - root_to_slots.get(root).is_some_and(|slots| { - slots.iter().any(|&slot| slot > state.latest_finalized.slot) - }) + let slot = root_to_slot[root]; + slot > state.latest_finalized.slot }); } } diff --git a/crates/blockchain/state_transition/tests/types.rs b/crates/blockchain/state_transition/tests/types.rs index aebb1c9..83e9c9f 100644 --- a/crates/blockchain/state_transition/tests/types.rs +++ b/crates/blockchain/state_transition/tests/types.rs @@ -25,6 +25,9 @@ impl StateTransitionTestVector { pub struct StateTransitionTest { #[allow(dead_code)] pub network: String, + #[serde(rename = "leanEnv")] + #[allow(dead_code)] + pub lean_env: String, pub pre: TestState, pub blocks: Vec, pub post: Option, @@ -185,35 +188,55 @@ impl From for ethlambda_types::block::Block { /// Block body containing attestations and other data #[derive(Debug, Clone, Deserialize)] pub struct BlockBody { - pub attestations: Container, + pub attestations: Container, } impl From for ethlambda_types::block::BlockBody { fn from(value: BlockBody) -> Self { - let attestations: Vec = value + let attestations = value .attestations .data .into_iter() - .map(|att| ethlambda_types::attestation::Attestation { - validator_id: att.validator_id, - data: att.data.into(), - }) + .map(Into::into) .collect(); - Self { attestations: VariableList::new(attestations).expect("too many attestations"), } } } -/// Individual attestation from test fixtures (unaggregated format) #[derive(Debug, Clone, Deserialize)] -pub struct Attestation { - #[serde(rename = "validatorId")] - pub validator_id: u64, +pub struct AggregatedAttestation { + #[serde(rename = "aggregationBits")] + pub aggregation_bits: AggregationBits, pub data: AttestationData, } +impl From for ethlambda_types::attestation::AggregatedAttestation { + fn from(value: AggregatedAttestation) -> Self { + Self { + aggregation_bits: value.aggregation_bits.into(), + data: value.data.into(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AggregationBits { + pub data: Vec, +} + +impl From for ethlambda_types::attestation::AggregationBits { + fn from(value: AggregationBits) -> Self { + let mut bits = + ethlambda_types::attestation::AggregationBits::with_capacity(value.data.len()).unwrap(); + for (i, &b) in value.data.iter().enumerate() { + bits.set(i, b).unwrap(); + } + bits + } +} + #[derive(Debug, Clone, Deserialize)] pub struct AttestationData { pub slot: u64, diff --git a/crates/blockchain/tests/forkchoice_spectests.rs b/crates/blockchain/tests/forkchoice_spectests.rs index a9f6b46..718ced4 100644 --- a/crates/blockchain/tests/forkchoice_spectests.rs +++ b/crates/blockchain/tests/forkchoice_spectests.rs @@ -28,15 +28,7 @@ fn run(path: &Path) -> datatest_stable::Result<()> { ) .into()); } - - // Skip lexicographic tiebreaker test - fork labels in fixture are incorrect - // (references labels like 'fork_a_3' that don't exist in earlier steps) - if name.contains("lexicographic_tiebreaker") { - println!("Skipping test (fixture has incorrect fork labels): {name}"); - continue; - } - - println!("Running test: {name}"); + println!("Running test: {}", name); // Initialize store from anchor state/block let anchor_state: State = test.anchor_state.into(); @@ -112,22 +104,15 @@ fn build_signed_block(block_data: types::BlockStepData) -> SignedBlockWithAttest let block: Block = block_data.block.into(); let proposer_attestation: Attestation = block_data.proposer_attestation.into(); - // Build flat signature list: one signature per attestation + proposer signature - // For tests, we use empty/default signatures since signature verification is skipped - let attestation_count = block.body.attestations.len(); - let mut signatures = Vec::with_capacity(attestation_count + 1); - for _ in 0..attestation_count { - signatures.push(Default::default()); - } - // Add proposer signature at the end - signatures.push(Default::default()); - SignedBlockWithAttestation { message: BlockWithAttestation { block, proposer_attestation, }, - signature: signatures.try_into().expect("signature count within limit"), + signature: BlockSignatures { + proposer_signature: Default::default(), + attestation_signatures: VariableList::empty(), + }, } } diff --git a/crates/blockchain/tests/signature_spectests.rs b/crates/blockchain/tests/signature_spectests.rs new file mode 100644 index 0000000..0318d35 --- /dev/null +++ b/crates/blockchain/tests/signature_spectests.rs @@ -0,0 +1,88 @@ +use std::path::Path; + +use ethlambda_blockchain::{SECONDS_PER_SLOT, store::Store}; +use ethlambda_types::{ + block::{Block, SignedBlockWithAttestation}, + primitives::TreeHash, + state::State, +}; + +mod signature_types; +use signature_types::VerifySignaturesTestVector; + +const SUPPORTED_FIXTURE_FORMAT: &str = "verify_signatures_test"; + +fn run(path: &Path) -> datatest_stable::Result<()> { + let tests = VerifySignaturesTestVector::from_file(path)?; + + for (name, test) in tests.tests { + if test.info.fixture_format != SUPPORTED_FIXTURE_FORMAT { + return Err(format!( + "Unsupported fixture format: {} (expected {})", + test.info.fixture_format, SUPPORTED_FIXTURE_FORMAT + ) + .into()); + } + + println!("Running test: {}", name); + + // Step 1: Populate the pre-state with the test fixture + let anchor_state: State = test.anchor_state.into(); + + // Create anchor block from the state's latest block header + let anchor_block = Block { + slot: anchor_state.latest_block_header.slot, + proposer_index: anchor_state.latest_block_header.proposer_index, + parent_root: anchor_state.latest_block_header.parent_root, + state_root: anchor_state.tree_hash_root(), + body: Default::default(), + }; + + // Initialize the store with the anchor state and block + let genesis_time = anchor_state.config.genesis_time; + let mut store = Store::get_forkchoice_store(anchor_state, anchor_block); + + // Step 2: Run the state transition function with the block fixture + let signed_block: SignedBlockWithAttestation = test.signed_block_with_attestation.into(); + + // Advance time to the block's slot + let block_time = signed_block.message.block.slot * SECONDS_PER_SLOT + genesis_time; + store.on_tick(block_time, true); + + // Process the block (this includes signature verification) + let result = store.on_block(signed_block); + + // Step 3: Check that it succeeded or failed as expected + match (result.is_ok(), test.expect_exception.as_ref()) { + (true, None) => { + // Expected success, got success + } + (true, Some(expected_err)) => { + return Err(format!( + "Test '{}' failed: expected exception '{}' but got success", + name, expected_err + ) + .into()); + } + (false, None) => { + return Err(format!( + "Test '{}' failed: expected success but got failure: {:?}", + name, + result.err() + ) + .into()); + } + (false, Some(_)) => { + // Expected failure, got failure + } + } + } + + Ok(()) +} + +datatest_stable::harness!({ + test = run, + root = "../../../ethlambda/leanSpec/fixtures/consensus/verify_signatures", + pattern = r".*\.json" +}); diff --git a/crates/blockchain/tests/signature_types.rs b/crates/blockchain/tests/signature_types.rs new file mode 100644 index 0000000..deff258 --- /dev/null +++ b/crates/blockchain/tests/signature_types.rs @@ -0,0 +1,526 @@ +use ethlambda_types::attestation::{ + AggregatedAttestation as EthAggregatedAttestation, AggregationBits as EthAggregationBits, + Attestation as EthAttestation, AttestationData as EthAttestationData, XmssSignature, +}; +use ethlambda_types::block::{ + AggregatedAttestations, AggregatedSignatureProof, AggregationBits as EthAggregationBitsSig, + AttestationSignatures, Block as EthBlock, BlockBody as EthBlockBody, BlockSignatures, + BlockWithAttestation, SignedBlockWithAttestation, +}; +use ethlambda_types::primitives::{BitList, Encode, H256, VariableList}; +use ethlambda_types::state::{Checkpoint as EthCheckpoint, State, ValidatorPubkeyBytes}; +use serde::Deserialize; +use ssz_derive::{Decode as SszDecode, Encode as SszEncode}; +use ssz_types::FixedVector; +use ssz_types::typenum::{U28, U32}; +use std::collections::HashMap; +use std::path::Path; + +// ============================================================================ +// SSZ Types matching leansig's GeneralizedXMSSSignature structure +// ============================================================================ + +/// A single hash digest (8 field elements = 32 bytes) +pub type HashDigest = FixedVector; + +/// Randomness (7 field elements = 28 bytes) +pub type Rho = FixedVector; + +/// SSZ-compatible HashTreeOpening matching leansig's structure +#[derive(Clone, SszEncode, SszDecode)] +pub struct SszHashTreeOpening { + pub co_path: Vec, +} + +/// SSZ-compatible XMSS Signature matching leansig's GeneralizedXMSSSignature +#[derive(Clone, SszEncode, SszDecode)] +pub struct SszXmssSignature { + pub path: SszHashTreeOpening, + pub rho: Rho, + pub hashes: Vec, +} + +/// Root struct for verify signatures test vectors +#[derive(Debug, Clone, Deserialize)] +pub struct VerifySignaturesTestVector { + #[serde(flatten)] + pub tests: HashMap, +} + +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)?; + Ok(test_vector) + } +} + +/// A single verify signatures test case +#[derive(Debug, Clone, Deserialize)] +pub struct VerifySignaturesTest { + #[allow(dead_code)] + pub network: String, + #[serde(rename = "leanEnv")] + #[allow(dead_code)] + pub lean_env: String, + #[serde(rename = "anchorState")] + pub anchor_state: TestState, + #[serde(rename = "signedBlockWithAttestation")] + pub signed_block_with_attestation: TestSignedBlockWithAttestation, + #[serde(rename = "expectException")] + pub expect_exception: Option, + #[serde(rename = "_info")] + #[allow(dead_code)] + pub info: TestInfo, +} + +/// Pre-state of the beacon chain for signature tests +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct TestState { + pub config: Config, + pub slot: u64, + #[serde(rename = "latestBlockHeader")] + pub latest_block_header: BlockHeader, + #[serde(rename = "latestJustified")] + pub latest_justified: Checkpoint, + #[serde(rename = "latestFinalized")] + pub latest_finalized: Checkpoint, + #[serde(rename = "historicalBlockHashes")] + pub historical_block_hashes: Container, + #[serde(rename = "justifiedSlots")] + pub justified_slots: Container, + pub validators: Container, + #[serde(rename = "justificationsRoots")] + pub justifications_roots: Container, + #[serde(rename = "justificationsValidators")] + pub justifications_validators: Container, +} + +impl From for State { + fn from(value: TestState) -> Self { + let historical_block_hashes = + VariableList::new(value.historical_block_hashes.data).unwrap(); + let validators = + VariableList::new(value.validators.data.into_iter().map(Into::into).collect()).unwrap(); + let justifications_roots = VariableList::new(value.justifications_roots.data).unwrap(); + + State { + config: value.config.into(), + slot: value.slot, + latest_block_header: value.latest_block_header.into(), + latest_justified: value.latest_justified.into(), + latest_finalized: value.latest_finalized.into(), + historical_block_hashes, + justified_slots: BitList::with_capacity(0).unwrap(), + validators, + justifications_roots, + justifications_validators: BitList::with_capacity(0).unwrap(), + } + } +} + +/// Configuration for the beacon chain +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + #[serde(rename = "genesisTime")] + pub genesis_time: u64, +} + +impl From for ethlambda_types::state::ChainConfig { + fn from(value: Config) -> Self { + ethlambda_types::state::ChainConfig { + genesis_time: value.genesis_time, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Checkpoint { + pub root: H256, + pub slot: u64, +} + +impl From for EthCheckpoint { + fn from(value: Checkpoint) -> Self { + Self { + root: value.root, + slot: value.slot, + } + } +} + +/// Block header representing the latest block +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct BlockHeader { + pub slot: u64, + #[serde(rename = "proposerIndex")] + pub proposer_index: u64, + #[serde(rename = "parentRoot")] + pub parent_root: H256, + #[serde(rename = "stateRoot")] + pub state_root: H256, + #[serde(rename = "bodyRoot")] + pub body_root: H256, +} + +impl From for ethlambda_types::block::BlockHeader { + fn from(value: BlockHeader) -> Self { + Self { + slot: value.slot, + proposer_index: value.proposer_index, + parent_root: value.parent_root, + state_root: value.state_root, + body_root: value.body_root, + } + } +} + +/// Validator information +#[derive(Debug, Clone, Deserialize)] +pub struct Validator { + pub index: u64, + #[serde(deserialize_with = "deser_pubkey_hex")] + pub pubkey: ValidatorPubkeyBytes, +} + +impl From for ethlambda_types::state::Validator { + fn from(value: Validator) -> Self { + Self { + index: value.index, + pubkey: value.pubkey, + } + } +} + +/// Generic container for arrays +#[derive(Debug, Clone, Deserialize)] +pub struct Container { + pub data: Vec, +} + +/// Signed block with attestation and signature +#[derive(Debug, Clone, Deserialize)] +pub struct TestSignedBlockWithAttestation { + pub message: TestBlockWithAttestation, + pub signature: TestSignatureBundle, +} + +impl From for SignedBlockWithAttestation { + fn from(value: TestSignedBlockWithAttestation) -> Self { + let message = BlockWithAttestation { + block: value.message.block.into(), + proposer_attestation: value.message.proposer_attestation.into(), + }; + + let proposer_signature = value.signature.proposer_signature.to_xmss_signature(); + + // Convert attestation signatures to AggregatedSignatureProof. + // Each proof contains the participants bitfield from the test data. + // The proof_data is currently empty (placeholder for future leanVM aggregation). + let attestation_signatures: AttestationSignatures = value + .signature + .attestation_signatures + .data + .into_iter() + .map(|att_sig| { + // Convert participants bitfield + let participants: EthAggregationBitsSig = att_sig.participants.into(); + // Create proof with participants but empty proof_data + AggregatedSignatureProof::empty(participants) + }) + .collect::>() + .try_into() + .expect("too many attestation signatures"); + + SignedBlockWithAttestation { + message, + signature: BlockSignatures { + attestation_signatures, + proposer_signature, + }, + } + } +} + +/// Block with proposer attestation (the message that gets signed) +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct TestBlockWithAttestation { + pub block: Block, + #[serde(rename = "proposerAttestation")] + pub proposer_attestation: ProposerAttestation, +} + +/// A block to be processed +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct Block { + pub slot: u64, + #[serde(rename = "proposerIndex")] + pub proposer_index: u64, + #[serde(rename = "parentRoot")] + pub parent_root: H256, + #[serde(rename = "stateRoot")] + pub state_root: H256, + pub body: BlockBody, +} + +impl From for EthBlock { + fn from(value: Block) -> Self { + Self { + slot: value.slot, + proposer_index: value.proposer_index, + parent_root: value.parent_root, + state_root: value.state_root, + body: value.body.into(), + } + } +} + +/// Block body containing attestations +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct BlockBody { + pub attestations: Container, +} + +impl From for EthBlockBody { + fn from(value: BlockBody) -> Self { + let attestations: AggregatedAttestations = value + .attestations + .data + .into_iter() + .map(Into::into) + .collect::>() + .try_into() + .expect("too many attestations"); + Self { attestations } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct AggregatedAttestation { + #[serde(rename = "aggregationBits")] + pub aggregation_bits: AggregationBits, + pub data: AttestationData, +} + +impl From for EthAggregatedAttestation { + fn from(value: AggregatedAttestation) -> Self { + Self { + aggregation_bits: value.aggregation_bits.into(), + data: value.data.into(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct AggregationBits { + pub data: Vec, +} + +impl From for EthAggregationBits { + fn from(value: AggregationBits) -> Self { + let mut bits = EthAggregationBits::with_capacity(value.data.len()).unwrap(); + for (i, &b) in value.data.iter().enumerate() { + bits.set(i, b).unwrap(); + } + bits + } +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct AttestationData { + pub slot: u64, + pub head: Checkpoint, + pub target: Checkpoint, + pub source: Checkpoint, +} + +impl From for EthAttestationData { + fn from(value: AttestationData) -> Self { + Self { + slot: value.slot, + head: value.head.into(), + target: value.target.into(), + source: value.source.into(), + } + } +} + +/// Proposer attestation structure +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct ProposerAttestation { + #[serde(rename = "validatorId")] + pub validator_id: u64, + pub data: AttestationData, +} + +impl From for EthAttestation { + fn from(value: ProposerAttestation) -> Self { + Self { + validator_id: value.validator_id, + data: value.data.into(), + } + } +} + +/// Bundle of signatures for block and attestations +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct TestSignatureBundle { + #[serde(rename = "proposerSignature")] + pub proposer_signature: ProposerSignature, + #[serde(rename = "attestationSignatures")] + pub attestation_signatures: Container, +} + +/// XMSS signature structure as it appears in JSON +#[derive(Debug, Clone, Deserialize)] +pub struct ProposerSignature { + pub path: SignaturePath, + pub rho: RhoData, + pub hashes: HashesData, +} + +impl ProposerSignature { + /// Convert to XmssSignature (FixedVector of bytes). + /// + /// Constructs an SSZ-encoded signature matching leansig's GeneralizedXMSSSignature format. + pub fn to_xmss_signature(&self) -> XmssSignature { + // Build SSZ types from JSON data + let ssz_sig = self.to_ssz_signature(); + + // Encode to SSZ bytes + let bytes = ssz_sig.as_ssz_bytes(); + + // Pad to exactly SignatureSize bytes (3112) + let sig_size = 3112; + let mut padded = bytes.clone(); + padded.resize(sig_size, 0); + + XmssSignature::new(padded).expect("signature size mismatch") + } + + /// Convert to SSZ signature type + fn to_ssz_signature(&self) -> SszXmssSignature { + // Convert path siblings to HashDigest (Vec of 32 bytes each) + let co_path: Vec = self + .path + .siblings + .data + .iter() + .map(|sibling| { + let bytes: Vec = sibling + .data + .iter() + .flat_map(|&val| val.to_le_bytes()) + .collect(); + HashDigest::new(bytes).expect("Invalid sibling length") + }) + .collect(); + + // Convert rho (7 field elements = 28 bytes) + let rho_bytes: Vec = self + .rho + .data + .iter() + .flat_map(|&val| val.to_le_bytes()) + .collect(); + let rho = Rho::new(rho_bytes).expect("Invalid rho length"); + + // Convert hashes to HashDigest + let hashes: Vec = self + .hashes + .data + .iter() + .map(|hash| { + let bytes: Vec = hash + .data + .iter() + .flat_map(|&val| val.to_le_bytes()) + .collect(); + HashDigest::new(bytes).expect("Invalid hash length") + }) + .collect(); + + SszXmssSignature { + path: SszHashTreeOpening { co_path }, + rho, + hashes, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SignaturePath { + pub siblings: Container, +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct HashElement { + pub data: [u32; 8], +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RhoData { + pub data: [u32; 7], +} + +#[derive(Debug, Clone, Deserialize)] +pub struct HashesData { + pub data: Vec, +} + +/// Attestation signature from a validator +/// Note: proofData is for future SNARK aggregation, currently just placeholder +#[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, +} + +/// Test metadata and information +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct TestInfo { + pub hash: String, + pub comment: String, + #[serde(rename = "testId")] + pub test_id: String, + pub description: String, + #[serde(rename = "fixtureFormat")] + pub fixture_format: String, +} + +// Helpers + +pub fn deser_pubkey_hex<'de, D>(d: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error; + + let value = String::deserialize(d)?; + let pubkey: ValidatorPubkeyBytes = hex::decode(value.strip_prefix("0x").unwrap_or(&value)) + .map_err(|_| D::Error::custom("ValidatorPubkey value is not valid hex"))? + .try_into() + .map_err(|_| D::Error::custom("ValidatorPubkey length != 52"))?; + Ok(pubkey) +} diff --git a/crates/blockchain/tests/types.rs b/crates/blockchain/tests/types.rs index 72b3b33..edb80f9 100644 --- a/crates/blockchain/tests/types.rs +++ b/crates/blockchain/tests/types.rs @@ -1,5 +1,9 @@ use ethlambda_types::{ - attestation::{Attestation as DomainAttestation, AttestationData as DomainAttestationData}, + attestation::{ + AggregatedAttestation as DomainAggregatedAttestation, + AggregationBits as DomainAggregationBits, Attestation as DomainAttestation, + AttestationData as DomainAttestationData, + }, block::{Block as DomainBlock, BlockBody as DomainBlockBody}, primitives::{BitList, H256, VariableList}, state::{ @@ -33,6 +37,9 @@ impl ForkChoiceTestVector { pub struct ForkChoiceTest { #[allow(dead_code)] pub network: String, + #[serde(rename = "leanEnv")] + #[allow(dead_code)] + pub lean_env: String, #[serde(rename = "anchorState")] pub anchor_state: TestState, #[serde(rename = "anchorBlock")] @@ -277,19 +284,16 @@ impl From for DomainBlock { #[derive(Debug, Clone, Deserialize)] pub struct BlockBody { - pub attestations: Container, + pub attestations: Container, } impl From for DomainBlockBody { fn from(value: BlockBody) -> Self { - let attestations: Vec = value + let attestations = value .attestations .data .into_iter() - .map(|att| DomainAttestation { - validator_id: att.validator_id, - data: att.data.into(), - }) + .map(Into::into) .collect(); Self { attestations: VariableList::new(attestations).expect("too many attestations"), @@ -297,14 +301,37 @@ impl From for DomainBlockBody { } } -/// Individual attestation from test fixtures (unaggregated format) #[derive(Debug, Clone, Deserialize)] -pub struct Attestation { - #[serde(rename = "validatorId")] - pub validator_id: u64, +pub struct AggregatedAttestation { + #[serde(rename = "aggregationBits")] + pub aggregation_bits: AggregationBits, pub data: AttestationData, } +impl From for DomainAggregatedAttestation { + fn from(value: AggregatedAttestation) -> Self { + Self { + aggregation_bits: value.aggregation_bits.into(), + data: value.data.into(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AggregationBits { + pub data: Vec, +} + +impl From for DomainAggregationBits { + fn from(value: AggregationBits) -> Self { + let mut bits = DomainAggregationBits::with_capacity(value.data.len()).unwrap(); + for (i, &b) in value.data.iter().enumerate() { + bits.set(i, b).unwrap(); + } + bits + } +} + // ============================================================================ // Attestation Types // ============================================================================ diff --git a/crates/common/types/src/attestation.rs b/crates/common/types/src/attestation.rs index 54bf42a..5040107 100644 --- a/crates/common/types/src/attestation.rs +++ b/crates/common/types/src/attestation.rs @@ -1,7 +1,10 @@ use ssz_derive::{Decode, Encode}; use tree_hash_derive::TreeHash; -use crate::{signature::SignatureSize, state::Checkpoint}; +use crate::{ + signature::SignatureSize, + state::{Checkpoint, ValidatorRegistryLimit}, +}; /// Validator specific attestation wrapping shared attestation data. #[derive(Debug, Clone, Encode, Decode, TreeHash)] @@ -41,3 +44,22 @@ pub struct SignedAttestation { } pub type XmssSignature = ssz_types::FixedVector; + +/// Aggregated attestation consisting of participation bits and message. +#[derive(Debug, Clone, Encode, Decode, TreeHash)] +pub struct AggregatedAttestation { + /// Bitfield indicating which validators participated in the aggregation. + pub aggregation_bits: AggregationBits, + + /// Combined attestation data similar to the beacon chain format. + /// + /// Multiple validator attestations are aggregated here without the complexity of + /// committee assignments. + pub data: AttestationData, +} + +/// Bitlist representing validator participation in an attestation or signature. +/// +/// A general-purpose bitfield for tracking which validators have participated +/// in some collective action (attestation, signature aggregation, etc.). +pub type AggregationBits = ssz_types::BitList; diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index 302194f..185cbe6 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -1,9 +1,10 @@ use ssz_derive::{Decode, Encode}; +use ssz_types::typenum::U1048576; use tree_hash_derive::TreeHash; use crate::{ - attestation::{Attestation, XmssSignature}, - primitives::H256, + attestation::{AggregatedAttestation, Attestation, XmssSignature}, + primitives::{ByteList, H256}, state::ValidatorRegistryLimit, }; @@ -35,16 +36,87 @@ impl core::fmt::Debug for SignedBlockWithAttestation { } } -/// Flat list of XMSS signatures for a block. +/// Signature payload for the block. +#[derive(Clone, Encode, Decode)] +pub struct BlockSignatures { + /// Attestation signatures for the aggregated attestations in the block body. + /// + /// Each entry corresponds to an aggregated attestation from the block body and + /// contains the leanVM aggregated signature proof bytes for the participating validators. + /// + /// TODO: + /// - Eventually this field will be replaced by a single SNARK aggregating *all* signatures. + pub attestation_signatures: AttestationSignatures, + + /// Signature for the proposer's attestation. + pub proposer_signature: XmssSignature, +} + +/// List of per-attestation aggregated signature proofs. /// -/// Signatures remain in attestation order followed by the proposer signature -/// over entire message. For devnet 1, however the proposer signature is just -/// over message.proposer_attestation since leanVM is not yet performant enough -/// to aggregate signatures with sufficient throughput. +/// Each entry corresponds to an aggregated attestation from the block body. +/// +/// It contains: +/// - the participants bitfield, +/// - proof bytes from leanVM signature aggregation. +pub type AttestationSignatures = + ssz_types::VariableList; + +/// Cryptographic proof that a set of validators signed a message. +/// +/// This container encapsulates the output of the leanVM signature aggregation, +/// combining the participant set with the proof bytes. This design ensures +/// the proof is self-describing: it carries information about which validators +/// it covers. +/// +/// The proof can verify that all participants signed the same message in the +/// same epoch, using a single verification operation instead of checking +/// each signature individually. +#[derive(Clone, Encode, Decode)] +pub struct AggregatedSignatureProof { + /// Bitfield indicating which validators' signatures are included. + pub participants: AggregationBits, + /// The raw aggregated proof bytes from leanVM. + pub proof_data: ByteListMiB, +} + +pub type ByteListMiB = ByteList; + +impl AggregatedSignatureProof { + /// Create a new aggregated signature proof. + pub fn new(participants: AggregationBits, proof_data: ByteListMiB) -> Self { + Self { + participants, + proof_data, + } + } + + /// Create an empty proof with the given participants bitfield. + /// + /// Used as a placeholder when actual aggregation is not yet implemented. + pub fn empty(participants: AggregationBits) -> Self { + Self { + participants, + proof_data: ByteList::empty(), + } + } + + /// Get the participants bitfield. + pub fn participants(&self) -> &AggregationBits { + &self.participants + } + + /// Get the proof data. + pub fn proof_data(&self) -> &ByteListMiB { + &self.proof_data + } +} + +/// Bitlist representing validator participation in an attestation or signature. /// -/// Ordering: [attestation_sig_0, attestation_sig_1, ..., attestation_sig_n, proposer_sig] -/// where signatures[i] corresponds to attestations[i] for i < n. -pub type BlockSignatures = ssz_types::VariableList; +/// A general-purpose bitfield for tracking which validators have participated +/// in some collective action (attestation, signature aggregation, etc.). +pub type AggregationBits = ssz_types::BitList; /// Bundle containing a block and the proposer's attestation. #[derive(Debug, Clone, Encode, Decode, TreeHash)] @@ -99,13 +171,13 @@ pub struct Block { /// packaged into blocks. #[derive(Debug, Default, Clone, Encode, Decode, TreeHash)] pub struct BlockBody { - /// Individual validator attestations carried in the block body. + /// Plain validator attestations carried in the block body. /// - /// Individual signatures live in the flat block signature list, so + /// Individual signatures live in the aggregated block signature list, so /// these entries contain only attestation data without per-attestation signatures. - /// Each attestation[i] corresponds to signature[i] in BlockSignatures. - pub attestations: Attestations, + pub attestations: AggregatedAttestations, } -/// List of individual attestations included in a block. -pub type Attestations = ssz_types::VariableList; +/// List of aggregated attestations included in a block. +pub type AggregatedAttestations = + ssz_types::VariableList; diff --git a/crates/net/p2p/src/gossipsub.rs b/crates/net/p2p/src/gossipsub.rs index 78092a3..aa2651e 100644 --- a/crates/net/p2p/src/gossipsub.rs +++ b/crates/net/p2p/src/gossipsub.rs @@ -82,8 +82,9 @@ mod tests { use ssz::Decode; #[test] + #[ignore = "Test data uses old BlockSignatures field order (proposer_signature, attestation_signatures). Needs regeneration with correct order (attestation_signatures, proposer_signature)."] fn test_decode_block() { - // Sample uncompressed block sent by Zeam (docker tag devnet1, version 41b3b11) + // Sample uncompressed block sent by Zeam (commit b153373806aa49f65aadc47c41b68ead4fab7d6e) let block_bytes = include_bytes!("../test_data/signed_block_with_attestation.ssz"); let _block = SignedBlockWithAttestation::from_ssz_bytes(block_bytes).unwrap(); } diff --git a/crates/net/p2p/test_data/signed_block_with_attestation.ssz b/crates/net/p2p/test_data/signed_block_with_attestation.ssz index 944545a..247868a 100644 Binary files a/crates/net/p2p/test_data/signed_block_with_attestation.ssz and b/crates/net/p2p/test_data/signed_block_with_attestation.ssz differ