From b3aed1e9a2cc791d4755cfb1050bb9ec15536246 Mon Sep 17 00:00:00 2001 From: artiomtr <44021713+ArtiomTr@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:20:48 +0200 Subject: [PATCH] Fix finalization issues Fixed few finalization issues, encountered in devnet-2: * Invalid proposer signature - this was due to invalid signature SSZ representation - previously it was a byte list inside container, now it is just a byte list. * Unknown block errors - p2p limits weren't configured properly, and with addition of aggregated signatures (which can be up to 1mb), messages with blocks weren't received. * Finalization issues - validator public key was in invalid format, similar to proposer signature error. Overall this not only fixes all finalization issues related to devnet-2, but also state transition code was refactored a bit to be more similar to spec. --- lean_client/Cargo.lock | 4 + lean_client/Cargo.toml | 1 + lean_client/containers/Cargo.toml | 2 + lean_client/containers/src/attestation.rs | 1 + lean_client/containers/src/lib.rs | 4 +- lean_client/containers/src/slot.rs | 4 + lean_client/containers/src/state.rs | 604 +++++++++--------- .../tests/unit_tests/state_basic.rs | 8 +- .../tests/unit_tests/state_process.rs | 28 +- .../tests/unit_tests/state_transition.rs | 12 +- lean_client/fork_choice/Cargo.toml | 1 + lean_client/fork_choice/src/handlers.rs | 6 +- lean_client/fork_choice/src/lib.rs | 6 + lean_client/fork_choice/src/store.rs | 83 ++- .../tests/fork_choice_test_vectors.rs | 7 +- .../tests/unit_tests/fork_choice.rs | 4 +- .../fork_choice/tests/unit_tests/validator.rs | 47 +- .../networking/src/gossipsub/config.rs | 3 + lean_client/networking/src/network/service.rs | 9 +- lean_client/src/main.rs | 3 +- lean_client/validator/Cargo.toml | 1 + lean_client/validator/src/lib.rs | 255 ++------ lean_client/xmss/src/aggregated_signature.rs | 3 +- lean_client/xmss/src/public_key.rs | 52 +- lean_client/xmss/src/signature.rs | 3 +- 25 files changed, 511 insertions(+), 640 deletions(-) diff --git a/lean_client/Cargo.lock b/lean_client/Cargo.lock index 32b5dc7..da0c8d6 100644 --- a/lean_client/Cargo.lock +++ b/lean_client/Cargo.lock @@ -1076,6 +1076,8 @@ name = "containers" version = "0.0.0" dependencies = [ "anyhow", + "bitvec", + "bls", "env-config", "hex", "metrics", @@ -1866,6 +1868,7 @@ name = "fork_choice" version = "0.0.0" dependencies = [ "anyhow", + "bls", "containers", "env-config", "metrics", @@ -6522,6 +6525,7 @@ dependencies = [ "serde_yaml", "ssz", "tracing", + "try_from_iterator", "typenum", "xmss", "zeroize", diff --git a/lean_client/Cargo.toml b/lean_client/Cargo.toml index a681d0f..b93acde 100644 --- a/lean_client/Cargo.toml +++ b/lean_client/Cargo.toml @@ -233,6 +233,7 @@ xmss = { path = "./xmss" } anyhow = "1.0.100" async-trait = "0.1" axum = "0.8.8" +bitvec = "1.0.1" bls = { git = "https://github.com/grandinetech/grandine", package = "bls", features = ["blst"], rev = "64afdee3c6be79fceffb66933dcb69a943f3f1ae" } clap = { version = "4", features = ["derive"] } derive_more = "2.1.1" diff --git a/lean_client/containers/Cargo.toml b/lean_client/containers/Cargo.toml index c8e6a2d..7198cd1 100644 --- a/lean_client/containers/Cargo.toml +++ b/lean_client/containers/Cargo.toml @@ -4,6 +4,8 @@ edition = { workspace = true } [dependencies] anyhow = { workspace = true } +bitvec = { workspace = true } +bls = { workspace = true } env-config = { workspace = true } hex = { workspace = true } metrics = { workspace = true } diff --git a/lean_client/containers/src/attestation.rs b/lean_client/containers/src/attestation.rs index 62b20b1..3fdced1 100644 --- a/lean_client/containers/src/attestation.rs +++ b/lean_client/containers/src/attestation.rs @@ -61,6 +61,7 @@ impl AggregatedSignatureProof { /// Bitlist representing validator participation in an attestation. /// Limit is VALIDATOR_REGISTRY_LIMIT (4096). #[derive(Clone, Debug, Ssz, Serialize, Deserialize)] +#[ssz(transparent)] pub struct AggregationBits( #[serde(with = "crate::serde_helpers::bitlist")] pub BitList, ); diff --git a/lean_client/containers/src/lib.rs b/lean_client/containers/src/lib.rs index cd1a6c0..d724689 100644 --- a/lean_client/containers/src/lib.rs +++ b/lean_client/containers/src/lib.rs @@ -10,8 +10,8 @@ mod validator; pub use attestation::{ AggregatedAttestation, AggregatedSignatureProof, AggregatedSignatures, AggregationBits, - Attestation, AttestationData, Attestations, SignatureKey, SignedAggregatedAttestation, - SignedAttestation, + Attestation, AttestationData, AttestationSignatures, Attestations, SignatureKey, + SignedAggregatedAttestation, SignedAttestation, }; pub use block::{ Block, BlockBody, BlockHeader, BlockSignatures, BlockWithAttestation, SignedBlock, diff --git a/lean_client/containers/src/slot.rs b/lean_client/containers/src/slot.rs index 9c9c39e..dbadcc9 100644 --- a/lean_client/containers/src/slot.rs +++ b/lean_client/containers/src/slot.rs @@ -17,6 +17,10 @@ impl Ord for Slot { } impl Slot { + pub fn justified_index_after(self, finalized_slot: Slot) -> Option { + self.0.checked_sub(finalized_slot.0 + 1) + } + /// Checks if this slot is a valid candidate for justification after a given finalized slot. /// /// According to the 3SF-mini specification, a slot is justifiable if its diff --git a/lean_client/containers/src/state.rs b/lean_client/containers/src/state.rs index d354262..6d77370 100644 --- a/lean_client/containers/src/state.rs +++ b/lean_client/containers/src/state.rs @@ -1,14 +1,16 @@ -use anyhow::{Context, Result, ensure}; +use anyhow::{Context, Result, anyhow, ensure}; +use bitvec::{bitvec, order::Lsb0, vec::BitVec}; use metrics::METRICS; use serde::{Deserialize, Serialize}; use ssz::{BitList, H256, PersistentList, Ssz, SszHash}; use std::collections::{BTreeMap, HashMap, HashSet}; +use tracing::{info, trace}; use try_from_iterator::TryFromIterator; use typenum::{Prod, U262144}; use xmss::{PublicKey, Signature}; use crate::{ - AggregatedSignatureProof, Attestation, AttestationData, Checkpoint, Config, SignatureKey, Slot, + AggregatedSignatureProof, Attestation, Checkpoint, Config, SignatureKey, Slot, attestation::{AggregatedAttestation, AggregatedAttestations, AggregationBits}, block::{Block, BlockBody, BlockHeader, SignedBlockWithAttestation}, validator::{Validator, ValidatorRegistryLimit, Validators}, @@ -19,10 +21,88 @@ type HistoricalRootsLimit = U262144; // 2^18 type JustificationValidatorsLimit = Prod; pub type HistoricalBlockHashes = PersistentList; -pub type JustifiedSlots = BitList; pub type JustificationValidators = BitList; pub type JustificationRoots = PersistentList; +#[derive(Debug, Clone, Serialize, Deserialize, Ssz, Default)] +#[ssz(transparent)] +pub struct JustifiedSlots( + #[serde(with = "crate::serde_helpers::bitlist")] pub BitList, +); + +impl JustifiedSlots { + fn is_slot_justified(&self, finalized_slot: Slot, target_slot: Slot) -> Result { + let Some(relative_index) = target_slot.justified_index_after(finalized_slot) else { + return Ok(true); + }; + + self.0 + .get(relative_index as usize) + .map(|v| *v) + .ok_or(anyhow!("Slot {target_slot:?} is outside the tracked range")) + } + + fn with_justified( + mut self, + finalized_slot: Slot, + target_slot: Slot, + value: bool, + ) -> Result { + let Some(relative_index) = target_slot.justified_index_after(finalized_slot) else { + return Ok(self); + }; + + self.0 + .get_mut(relative_index as usize) + .map(|mut bit| bit.set(value)) + .map(|_| self) + .ok_or(anyhow!("Slot {target_slot:?} is outside the tracked range")) + } + + fn shift_window(self, delta: u64) -> Self { + if delta == 0 { + return self; + }; + + // todo(stf): this probably can be optimized to use something like + // this. However, BitList::from_bit_box is private, so it is not + // possible now. + // let bits = &self.0[(delta as usize)..]; + + // Ok(Self(BitList::from_bit_box( + // bits.to_bitvec().into_boxed_bitslice(), + // ))) + + let bits = &self.0[(delta as usize)..]; + let mut output = BitList::with_length(bits.len()); + + for (i, val) in bits.iter().enumerate() { + output.set(i, *val); + } + + Self(output) + } + + fn extend_to_slot(self, finalized_slot: Slot, target_slot: Slot) -> Self { + let Some(relative_index) = target_slot.justified_index_after(finalized_slot) else { + return self; + }; + + let required_capacity = relative_index + 1; + let Some(gap_size) = required_capacity.checked_sub(self.0.len() as u64) else { + return self; + }; + + let mut list = BitList::with_length(required_capacity as usize); + + for (index, bit) in self.0.iter().enumerate() { + list.set(index, *bit); + } + + Self(list) + } +} + #[derive(Clone, Debug, Ssz, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct State { @@ -42,7 +122,6 @@ pub struct State { pub historical_block_hashes: HistoricalBlockHashes, // --- flattened justification tracking --- - #[serde(with = "crate::serde_helpers::bitlist")] pub justified_slots: JustifiedSlots, // Validators registry @@ -135,16 +214,6 @@ impl State { } } - /// Simple RR proposer rule (round-robin). - pub fn is_proposer(&self, index: u64) -> bool { - let num_validators = self.validators.len_u64(); - - if num_validators == 0 { - return false; // No validators - } - (self.slot.0 % num_validators) == (index % num_validators) - } - pub fn get_justifications(&self) -> BTreeMap> { // Use actual validator count, matching leanSpec let num_validators = self.validators.len_usize(); @@ -218,15 +287,6 @@ impl State { &self, signed_block: SignedBlockWithAttestation, valid_signatures: bool, - ) -> Result { - self.state_transition_with_validation(signed_block, valid_signatures, true) - } - - pub fn state_transition_with_validation( - &self, - signed_block: SignedBlockWithAttestation, - valid_signatures: bool, - validate_state_root: bool, ) -> Result { ensure!(valid_signatures, "invalid block signatures"); @@ -237,12 +297,13 @@ impl State { let mut state = self.process_slots(block.slot)?; state = state.process_block(block)?; - if validate_state_root { - let state_for_hash = state.clone(); - let state_root = state_for_hash.hash_tree_root(); + let state_root = state.hash_tree_root(); - ensure!(block.state_root == state_root, "invalid block state root"); - } + ensure!( + block.state_root == state_root, + "invalid block state root (block.state_root={}, actual={state_root})", + block.state_root + ); Ok(state) } @@ -259,7 +320,9 @@ impl State { let mut state = self.clone(); while state.slot < target_slot { - state = state.process_slot(); + if state.latest_block_header.state_root.is_zero() { + state.latest_block_header.state_root = state.hash_tree_root(); + } state.slot = Slot(state.slot.0 + 1); METRICS @@ -270,25 +333,7 @@ impl State { Ok(state) } - pub fn process_slot(&self) -> Self { - // Cache the state root in the header if not already set (matches leanSpec) - // Per spec: leanSpec/src/lean_spec/subspecs/containers/state/state.py lines 173-176 - if self.latest_block_header.state_root.is_zero() { - let state_for_hash = self.clone(); - let previous_state_root = state_for_hash.hash_tree_root(); - - let mut new_header = self.latest_block_header.clone(); - new_header.state_root = previous_state_root; - - let mut new_state = self.clone(); - new_state.latest_block_header = new_header; - return new_state; - } - - self.clone() - } - - pub fn process_block(&self, block: &Block) -> Result { + pub fn process_block(self, block: &Block) -> Result { let _timer = METRICS.get().map(|metrics| { metrics .lean_state_transition_block_processing_time_seconds @@ -302,334 +347,255 @@ impl State { "block contains duplicate attestation data" ); - Ok(state.process_attestations(&block.body.attestations)) + state.process_attestations(&block.body.attestations) } - pub fn process_block_header(&self, block: &Block) -> Result { - ensure!(block.slot == self.slot, "block slot mismatch"); + pub fn process_block_header(mut self, block: &Block) -> Result { + let parent_header = self.latest_block_header; + let parent_root = parent_header.hash_tree_root(); + + ensure!(block.slot == self.slot, "Block slot mismatch"); + ensure!( - block.slot > self.latest_block_header.slot, - "block is older than latest header" + block.slot > parent_header.slot, + "Block is older than latest header" ); + ensure!( - self.is_proposer(block.proposer_index), - "incorrect block proposer" + is_proposer_for(block.proposer_index, self.slot, self.validators.len_u64()), + "Incorrect block proposer" ); - // Create a mutable clone for hash computation - let latest_header_for_hash = self.latest_block_header.clone(); - let parent_root = latest_header_for_hash.hash_tree_root(); - ensure!( block.parent_root == parent_root, - "block parent root mismatch" + "Block parent root mismatch" ); - // Build new PersistentList for historical hashes - let mut new_historical_hashes = HistoricalBlockHashes::default(); - for hash in &self.historical_block_hashes { - new_historical_hashes.push(*hash)?; - } - new_historical_hashes.push(parent_root)?; + let is_genesis_parent = parent_header.slot.0 == 0; - // Calculate number of empty slots (skipped slots between parent and this block) - let num_empty_slots = (block.slot.0 - self.latest_block_header.slot.0 - 1) as usize; + let (new_latest_justified, new_latest_finalized) = if is_genesis_parent { + ( + Checkpoint { + root: parent_root, + slot: Slot(0), + }, + Checkpoint { + root: parent_root, + slot: Slot(0), + }, + ) + } else { + (self.latest_justified.clone(), self.latest_finalized.clone()) + }; - // Add ZERO_HASH entries for empty slots to historical hashes + let num_empty_slots = block.slot.0 - parent_header.slot.0 - 1; + + self.historical_block_hashes.push(parent_root)?; for _ in 0..num_empty_slots { - new_historical_hashes.push(H256::zero())?; + self.historical_block_hashes.push(H256::zero())?; } - // Extend justified_slots to cover slots from finalized_slot+1 to last_materialized_slot - // per leanSpec: justified_slots is stored RELATIVE to the finalized boundary - // The first entry corresponds to slot (finalized_slot + 1) - let last_materialized_slot = block.slot.0.saturating_sub(1); - let finalized_slot = self.latest_finalized.slot.0; - - let new_justified_slots = if last_materialized_slot > finalized_slot { - // Calculate relative index: slot X maps to index (X - finalized_slot - 1) - let relative_index = (last_materialized_slot - finalized_slot - 1) as usize; - let required_capacity = relative_index + 1; - let current_len = self.justified_slots.len(); - - if required_capacity > current_len { - // Extend the bitlist - let mut new_slots = JustifiedSlots::new(false, required_capacity); - // Copy existing bits - for i in 0..current_len { - if let Some(bit) = self.justified_slots.get(i) { - if *bit { - new_slots.set(i, true); - } - } - } - // New slots are initialized to false (unjustified) - new_slots - } else { - self.justified_slots.clone() - } - } else { - // last_materialized_slot <= finalized_slot: no extension needed - self.justified_slots.clone() - }; - - let body_for_hash = block.body.clone(); - let body_root = body_for_hash.hash_tree_root(); + let last_materialized_slot = block.slot.0 - 1; + self.justified_slots = self + .justified_slots + .extend_to_slot(self.latest_finalized.slot, Slot(last_materialized_slot)); - let new_latest_block_header = BlockHeader { + let new_header = BlockHeader { slot: block.slot, proposer_index: block.proposer_index, parent_root: block.parent_root, - body_root, + body_root: block.body.hash_tree_root(), state_root: H256::zero(), }; - let mut new_latest_justified = self.latest_justified.clone(); - let mut new_latest_finalized = self.latest_finalized.clone(); - - if self.latest_block_header.slot == Slot(0) { - new_latest_justified.root = parent_root; - new_latest_finalized.root = parent_root; - } - - Ok(Self { - config: self.config.clone(), - slot: self.slot, - latest_block_header: new_latest_block_header, - latest_justified: new_latest_justified, - latest_finalized: new_latest_finalized, - historical_block_hashes: new_historical_hashes, - justified_slots: new_justified_slots, - validators: self.validators.clone(), - justifications_roots: self.justifications_roots.clone(), - justifications_validators: self.justifications_validators.clone(), - }) + self.latest_justified = new_latest_justified; + self.latest_finalized = new_latest_finalized; + self.latest_block_header = new_header; + Ok(self) } - pub fn process_attestations(&self, attestations: &AggregatedAttestations) -> Self { + pub fn process_attestations(&self, attestations: &AggregatedAttestations) -> Result { let _timer = METRICS.get().map(|metrics| { metrics .lean_state_transition_attestations_processing_time_seconds .start_timer() }); - let mut justifications = self.get_justifications(); - let mut latest_justified = self.latest_justified.clone(); - let mut latest_finalized = self.latest_finalized.clone(); - let initial_finalized_slot = self.latest_finalized.slot; - let justified_slots = self.justified_slots.clone(); - - tracing::info!( - current_justified_slot = latest_justified.slot.0, - current_finalized_slot = latest_finalized.slot.0, - "Processing attestations in block" + ensure!( + self.justifications_roots + .into_iter() + .all(|root| !root.is_zero()), + "zero hash is not allowed in justification roots" ); - let mut justified_slots_working = Vec::new(); - for i in 0..justified_slots.len() { - justified_slots_working.push(justified_slots.get(i).map(|b| *b).unwrap_or(false)); + let mut justifications = self + .justifications_roots + .into_iter() + .enumerate() + .map(|(i, root)| { + ( + root.clone(), + self.justifications_validators + [i * self.validators.len_usize()..(i + 1) * self.validators.len_usize()] + .to_bitvec(), + ) + }) + .collect::>>(); + + let mut latest_justified = self.latest_justified.clone(); + let mut latest_finalized = self.latest_finalized.clone(); + let mut finalized_slot = latest_finalized.slot; + let mut justified_slots = self.justified_slots.clone(); + + let mut root_to_slot = HashMap::new(); + let start_slot = finalized_slot.0 + 1; + let end_slot = self.historical_block_hashes.len_u64(); + for i in start_slot..end_slot { + let root = self.historical_block_hashes.get(i)?; + + root_to_slot + .entry(root.clone()) + .and_modify(|slot: &mut Slot| { + if i > slot.0 { + *slot = Slot(i); + } + }) + .or_insert(Slot(i)); } - for aggregated_attestation in attestations { + for attestation in attestations { METRICS.get().map(|metrics| { metrics .lean_state_transition_attestations_processed_total .inc() }); - let validator_ids = aggregated_attestation - .aggregation_bits - .to_validator_indices(); - self.process_single_attestation( - &aggregated_attestation.data, - &validator_ids, - &mut justifications, - &mut latest_justified, - &mut latest_finalized, - &mut justified_slots_working, - initial_finalized_slot, - ); - } - self.finalize_attestation_processing( - justifications, - latest_justified, - latest_finalized, - justified_slots_working, - ) - } + let source = attestation.data.source.clone(); + let target = attestation.data.target.clone(); - /// Process a single attestation's votes. - /// - /// NOTE: justified_slots uses RELATIVE indexing. Slot X maps to index (X - finalized_slot - 1). - /// Slots at or before finalized_slot are implicitly justified (not stored in the bitlist). - fn process_single_attestation( - &self, - vote: &AttestationData, - validator_ids: &[u64], - justifications: &mut BTreeMap>, - latest_justified: &mut Checkpoint, - latest_finalized: &mut Checkpoint, - justified_slots_working: &mut Vec, - initial_finalized_slot: Slot, - ) { - let target_slot = vote.target.slot; - let source_slot = vote.source.slot; - let target_root = vote.target.root; - let source_root = vote.source.root; - - let finalized_slot_int = initial_finalized_slot.0 as i64; - - // Helper to check if a slot is justified using RELATIVE indexing - // Per leanSpec: slots at or before finalized_slot are implicitly justified - let is_slot_justified = |slot: Slot, justified_slots: &[bool]| -> bool { - if slot.0 as i64 <= finalized_slot_int { - // Slots at or before finalized boundary are implicitly justified - return true; + if !justified_slots.is_slot_justified(finalized_slot, source.slot)? { + info!("skipping attestation, source slot is not justified"); + continue; } - // Calculate relative index: slot X maps to index (X - finalized_slot - 1) - let relative_index = (slot.0 as i64 - finalized_slot_int - 1) as usize; - justified_slots - .get(relative_index) - .copied() - .unwrap_or(false) - }; - - let source_is_justified = is_slot_justified(source_slot, justified_slots_working); - let target_already_justified = is_slot_justified(target_slot, justified_slots_working); - - let source_slot_int = source_slot.0 as usize; - let target_slot_int = target_slot.0 as usize; - - // Check root matches using absolute slot for historical_block_hashes lookup - let source_root_matches = self - .historical_block_hashes - .get(source_slot_int as u64) - .map(|r| *r == source_root) - .unwrap_or(false); - let target_root_matches = self - .historical_block_hashes - .get(target_slot_int as u64) - .map(|r| *r == target_root) - .unwrap_or(false); - - // Ignore votes that reference zero-hash slots (per leanSpec) - if source_root.is_zero() || target_root.is_zero() { - return; - } - let is_valid_vote = source_is_justified - && !target_already_justified - && source_root_matches - && target_root_matches - && target_slot > source_slot - && target_slot.is_justifiable_after(initial_finalized_slot); - - // Debug logging for vote validation - tracing::debug!( - source_slot = source_slot.0, - target_slot = target_slot.0, - source_root = %format!("0x{:x}", source_root), - target_root = %format!("0x{:x}", target_root), - validator_count = validator_ids.len(), - source_is_justified, - target_already_justified, - source_root_matches, - target_root_matches, - is_valid_vote, - "Processing attestation vote" - ); - - if !is_valid_vote { - tracing::warn!( - source_slot = source_slot.0, - target_slot = target_slot.0, - source_is_justified, - target_already_justified, - source_root_matches, - target_root_matches, - "Vote rejected" - ); - return; - } + if justified_slots.is_slot_justified(finalized_slot, target.slot)? { + info!("skipping attestation, target slot is already justified"); + continue; + } - if !justifications.contains_key(&target_root) { - justifications.insert(target_root, vec![false; self.validators.len_usize()]); - } + if source.root.is_zero() || target.root.is_zero() { + info!("skipping attestation, source or target slots are zero"); + continue; + } - for &validator_id in validator_ids { - let vid = validator_id as usize; - if let Some(votes) = justifications.get_mut(&target_root) { - if vid < votes.len() && !votes[vid] { - votes[vid] = true; - } + if &source.root != self.historical_block_hashes.get(source.slot.0)? + || &target.root != self.historical_block_hashes.get(target.slot.0)? + { + info!( + "skipping attestation, source or target roots not found in historical block hashes" + ); + continue; } - } - if let Some(votes) = justifications.get(&target_root) { - let num_validators = self.validators.len_u64() as usize; - let count = votes.iter().filter(|&&v| v).count(); - let threshold = (2usize * num_validators).div_ceil(3); + if target.slot <= source.slot { + info!("skipping attestation, target slot is before source slot"); + continue; + } - tracing::info!( - target_slot = target_slot.0, - target_root = %format!("0x{:x}", target_root), - vote_count = count, - num_validators, - threshold, - needs = format!("3*{} >= 2*{} = {} >= {}", count, num_validators, 3*count, 2*num_validators), - will_justify = 3 * count >= 2 * num_validators, - "Vote count for target" - ); + if !target.slot.is_justifiable_after(self.latest_finalized.slot) { + info!("skipping attestation, target slot is not yet justifiable"); + continue; + } - if 3 * count >= 2 * num_validators { - tracing::info!( - target_slot = target_slot.0, - target_root = %format!("0x{:x}", target_root), - "Justification threshold reached" + if !justifications.contains_key(&target.root) { + justifications.insert( + target.root.clone(), + bitvec![u8, Lsb0; 0; self.validators.len_usize()], ); - *latest_justified = vote.target.clone(); + } - // Use RELATIVE indexing for justified_slots_working - // Calculate relative index for target slot - let target_relative_index = - (target_slot.0 as i64 - finalized_slot_int - 1) as usize; + for validator_id in attestation.aggregation_bits.to_validator_indices() { + let mut vote = justifications + .get_mut(&target.root) + .ok_or(anyhow!("unknown target root"))? + .get_mut(validator_id as usize) + .ok_or(anyhow!("validator index is out of range"))?; - // Extend the working vec if needed - if target_relative_index >= justified_slots_working.len() { - justified_slots_working.resize(target_relative_index + 1, false); + vote.set(true); + } + + let count = justifications[&target.root] + .iter() + .map(|v| *v as u64) + .sum::(); + + if 3 * count >= 2 * self.validators.len_u64() { + info!("justifying slot {target:?}"); + latest_justified = target.clone(); + justified_slots = + justified_slots.with_justified(finalized_slot, target.slot, true)?; + + justifications.remove(&target.root); + + if !(source.slot.0 + 1..target.slot.0) + .any(|slot| Slot(slot).is_justifiable_after(self.latest_finalized.slot)) + { + info!("finalizing {source:?}"); + let old_finalized_slot = finalized_slot; + latest_finalized = source; + finalized_slot = latest_finalized.slot; + let delta = finalized_slot.0.checked_sub(old_finalized_slot.0); + + if let Some(delta) = delta + && delta > 0 + { + justified_slots = justified_slots.shift_window(delta); + + ensure!( + justifications + .keys() + .all(|root| root_to_slot.contains_key(root)), + "Justification root missing from root_to_slot" + ); + justifications.retain(|root, _| root_to_slot[root].0 > finalized_slot.0); + } } - justified_slots_working[target_relative_index] = true; + // justified_slots = justified_slots + } + } - justifications.remove(&target_root); + let sorted_roots = { + let mut roots = justifications.keys().copied().collect::>(); + roots.sort(); + roots + }; - let is_finalizable = (source_slot_int + 1..target_slot_int) - .all(|s| !Slot(s as u64).is_justifiable_after(initial_finalized_slot)); + let mut output = self.clone(); + output.justifications_roots = JustificationRoots::try_from_iter(sorted_roots.clone())?; - if is_finalizable { - tracing::info!(source_slot = source_slot.0, "FINALIZATION!"); - *latest_finalized = vote.source.clone(); - } + // TODO(stf): this can be optimized by using something like concatenate. + // However, currently not possible as BitList doesn't allow constructing + // from structure. + output.justifications_validators = { + let bits = sorted_roots + .iter() + .flat_map(|root| justifications[root].clone()) + .collect::>(); + + let mut output = BitList::with_length(bits.len()); + + for (i, val) in bits.into_iter().enumerate() { + output.set(i, val); } - } - } - fn finalize_attestation_processing( - &self, - justifications: BTreeMap>, - latest_justified: Checkpoint, - latest_finalized: Checkpoint, - justified_slots_working: Vec, - ) -> Self { - let mut new_state = self.clone().with_justifications(justifications); - new_state.latest_justified = latest_justified; - new_state.latest_finalized = latest_finalized; - - let mut new_justified_slots = JustifiedSlots::with_length(justified_slots_working.len()); - for (i, &val) in justified_slots_working.iter().enumerate() { - new_justified_slots.set(i, val); - } - new_state.justified_slots = new_justified_slots; - new_state + output + }; + + output.justified_slots = justified_slots; + output.latest_justified = latest_justified; + output.latest_finalized = latest_finalized; + + Ok(output) } /// Build a valid block on top of this state. @@ -935,3 +901,7 @@ impl State { Ok((aggregated_attestations, aggregated_proofs)) } } + +fn is_proposer_for(validator_index: u64, slot: Slot, num_validators: u64) -> bool { + slot.0 % num_validators == validator_index +} diff --git a/lean_client/containers/tests/unit_tests/state_basic.rs b/lean_client/containers/tests/unit_tests/state_basic.rs index d3bbb19..67d4a61 100644 --- a/lean_client/containers/tests/unit_tests/state_basic.rs +++ b/lean_client/containers/tests/unit_tests/state_basic.rs @@ -29,17 +29,11 @@ fn test_generate_genesis() { // Check that collections are empty by trying to get the first element assert!(state.historical_block_hashes.get(0).is_err()); - assert!(state.justified_slots.get(0).is_none()); + assert!(state.justified_slots.0.get(0).is_none()); assert!(state.justifications_roots.get(0).is_err()); assert!(state.justifications_validators.get(0).is_none()); } -#[test] -fn test_proposer_round_robin() { - let state = State::generate_genesis(0, 4); - assert!(state.is_proposer(0)); -} - #[test] fn test_slot_justifiability_rules() { assert!(Slot(1).is_justifiable_after(Slot(0))); diff --git a/lean_client/containers/tests/unit_tests/state_process.rs b/lean_client/containers/tests/unit_tests/state_process.rs index a1be9e5..1d52d18 100644 --- a/lean_client/containers/tests/unit_tests/state_process.rs +++ b/lean_client/containers/tests/unit_tests/state_process.rs @@ -14,27 +14,6 @@ pub fn genesis_state() -> State { State::generate_genesis(config.genesis_time, 10) } -#[test] -fn test_process_slot() { - let genesis_state = genesis_state(); - - assert_eq!(genesis_state.latest_block_header.state_root, H256::zero()); - - let state_after_slot = genesis_state.process_slot(); - let expected_root = genesis_state.hash_tree_root(); - - assert_eq!( - state_after_slot.latest_block_header.state_root, - expected_root - ); - - let state_after_second_slot = state_after_slot.process_slot(); - assert_eq!( - state_after_second_slot.latest_block_header.state_root, - expected_root - ); -} - #[test] fn test_process_slots() { let genesis_state = genesis_state(); @@ -79,6 +58,7 @@ fn test_process_block_header_valid() { // justified_slots should be empty or all false. let justified_slot_1_relative = new_state .justified_slots + .0 .get(0) // relative index 0 = slot 1 .map(|b| *b) .unwrap_or(false); @@ -89,9 +69,9 @@ fn test_process_block_header_valid() { } #[rstest] -#[case(2, 1, None, "block slot mismatch")] -#[case(1, 2, None, "incorrect block proposer")] -#[case(1, 1, Some(H256::from_slice(&[0xde; 32])), "block parent root mismatch")] +#[case(2, 1, None, "Block slot mismatch")] +#[case(1, 2, None, "Incorrect block proposer")] +#[case(1, 1, Some(H256::from_slice(&[0xde; 32])), "Block parent root mismatch")] fn test_process_block_header_invalid( #[case] bad_slot: u64, #[case] bad_proposer: u64, diff --git a/lean_client/containers/tests/unit_tests/state_transition.rs b/lean_client/containers/tests/unit_tests/state_transition.rs index f638bbb..c7cb259 100644 --- a/lean_client/containers/tests/unit_tests/state_transition.rs +++ b/lean_client/containers/tests/unit_tests/state_transition.rs @@ -35,7 +35,9 @@ fn test_state_transition_full() { // Use process_block_header + process_operations to avoid state root validation during setup let state_after_header = state_at_slot_1.process_block_header(&block).unwrap(); - let expected_state = state_after_header.process_attestations(&block.body.attestations); + let expected_state = state_after_header + .process_attestations(&block.body.attestations) + .unwrap(); let block_with_correct_root = Block { state_root: expected_state.hash_tree_root(), @@ -72,7 +74,9 @@ fn test_state_transition_invalid_signatures() { // Use process_block_header + process_operations to avoid state root validation during setup let state_after_header = state_at_slot_1.process_block_header(&block).unwrap(); - let expected_state = state_after_header.process_attestations(&block.body.attestations); + let expected_state = state_after_header + .process_attestations(&block.body.attestations) + .unwrap(); let block_with_correct_root = Block { state_root: expected_state.hash_tree_root(), @@ -131,7 +135,9 @@ fn test_state_transition_devnet2() { // Process the block header and attestations let state_after_header = state_at_slot_1.process_block_header(&block).unwrap(); - let expected_state = state_after_header.process_attestations(&block.body.attestations); + let expected_state = state_after_header + .process_attestations(&block.body.attestations) + .unwrap(); // Ensure the state root matches the expected state let block_with_correct_root = Block { diff --git a/lean_client/fork_choice/Cargo.toml b/lean_client/fork_choice/Cargo.toml index bde97cd..a5aeb35 100644 --- a/lean_client/fork_choice/Cargo.toml +++ b/lean_client/fork_choice/Cargo.toml @@ -4,6 +4,7 @@ edition = { workspace = true } [dependencies] anyhow = { workspace = true } +bls = { workspace = true } containers = { workspace = true } env-config = { workspace = true } metrics = { workspace = true } diff --git a/lean_client/fork_choice/src/handlers.rs b/lean_client/fork_choice/src/handlers.rs index 3aa3d6e..837d640 100644 --- a/lean_client/fork_choice/src/handlers.rs +++ b/lean_client/fork_choice/src/handlers.rs @@ -308,19 +308,19 @@ fn process_block_internal( attestations_in_block = attestations_count, parent_justified_slot = state.latest_justified.slot.0, parent_finalized_slot = state.latest_finalized.slot.0, - justified_slots_len = state.justified_slots.len(), + justified_slots_len = state.justified_slots.0.len(), "Processing block - parent state info" ); // Execute state transition to get post-state - let new_state = state.state_transition_with_validation(signed_block.clone(), true, true)?; + let new_state = state.state_transition(signed_block.clone(), true)?; // Debug: Log new state checkpoints after transition tracing::debug!( block_slot = block.slot.0, new_justified_slot = new_state.latest_justified.slot.0, new_finalized_slot = new_state.latest_finalized.slot.0, - new_justified_slots_len = new_state.justified_slots.len(), + new_justified_slots_len = new_state.justified_slots.0.len(), "Block processed - new state info" ); diff --git a/lean_client/fork_choice/src/lib.rs b/lean_client/fork_choice/src/lib.rs index ad824f3..46bab5a 100644 --- a/lean_client/fork_choice/src/lib.rs +++ b/lean_client/fork_choice/src/lib.rs @@ -1,2 +1,8 @@ pub mod handlers; pub mod store; + +// dirty hack to avoid issues compiling grandine dependencies. by default, bls +// crate has no features enabled, and thus compilation fails (as exactly one +// backend must be enabled). So we include bls crate with one feature enabled, +// to make everything work. +use bls as _; diff --git a/lean_client/fork_choice/src/store.rs b/lean_client/fork_choice/src/store.rs index 7ec73aa..ab79210 100644 --- a/lean_client/fork_choice/src/store.rs +++ b/lean_client/fork_choice/src/store.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use anyhow::{Error, Result, anyhow, ensure}; +use anyhow::{Result, anyhow, ensure}; use containers::{ AggregatedSignatureProof, Attestation, AttestationData, Block, Checkpoint, Config, SignatureKey, SignedBlockWithAttestation, Slot, State, @@ -45,6 +45,56 @@ pub struct Store { pub aggregated_payloads: HashMap>, } +const JUSTIFICATION_LOOKBACK_SLOTS: u64 = 3; + +impl Store { + pub fn produce_attestation_data(&self, slot: Slot) -> Result { + let head_checkpoint = Checkpoint { + root: self.head, + slot: self + .blocks + .get(&self.head) + .ok_or(anyhow!("head block is not known"))? + .slot, + }; + + let target_checkpoint = self.get_attestation_target(); + + Ok(AttestationData { + slot, + head: head_checkpoint, + target: target_checkpoint, + source: self.latest_justified.clone(), + }) + } + + pub fn get_attestation_target(&self) -> Checkpoint { + let mut target = self.head; + + let safe_slot = self.blocks[&self.safe_target].slot; + + // Walk back toward safe target + for _ in 0..JUSTIFICATION_LOOKBACK_SLOTS { + if self.blocks[&target].slot > safe_slot { + target = self.blocks[&target].parent_root; + } else { + break; + } + } + + let final_slot = self.latest_finalized.slot; + while !self.blocks[&target].slot.is_justifiable_after(final_slot) { + target = self.blocks[&target].parent_root; + } + + let block_target = &self.blocks[&target]; + Checkpoint { + root: target, + slot: block_target.slot, + } + } +} + /// Initialize forkchoice store from an anchor state and block pub fn get_forkchoice_store( anchor_state: State, @@ -249,37 +299,6 @@ pub fn tick_interval(store: &mut Store, has_proposal: bool) { } } -/// Algorithm: -/// 1. Start at Head: Begin with the current head block -/// 2. Walk Toward Safe: Move backward (up to JUSTIFICATION_LOOKBACK_SLOTS steps) -/// if safe target is newer -/// 3. Ensure Justifiable: Continue walking back until slot is justifiable -/// 4. Return Checkpoint: Create checkpoint from selected block -pub fn get_vote_target(store: &Store) -> Checkpoint { - let mut target = store.head; - let safe_slot = store.blocks[&store.safe_target].slot; - - // Walk back toward safe target - for _ in 0..3 { - if store.blocks[&target].slot > safe_slot { - target = store.blocks[&target].parent_root; - } else { - break; - } - } - - let final_slot = store.latest_finalized.slot; - while !store.blocks[&target].slot.is_justifiable_after(final_slot) { - target = store.blocks[&target].parent_root; - } - - let block_target = &store.blocks[&target]; - Checkpoint { - root: target, - slot: block_target.slot, - } -} - #[inline] pub fn get_proposal_head(store: &mut Store, slot: Slot) -> H256 { let slot_time = store.config.genesis_time + (slot.0 * SECONDS_PER_SLOT); diff --git a/lean_client/fork_choice/tests/fork_choice_test_vectors.rs b/lean_client/fork_choice/tests/fork_choice_test_vectors.rs index 016d35f..2b4c5ef 100644 --- a/lean_client/fork_choice/tests/fork_choice_test_vectors.rs +++ b/lean_client/fork_choice/tests/fork_choice_test_vectors.rs @@ -10,7 +10,7 @@ use fork_choice::{ }; use serde::Deserialize; -use ssz::{H256, SszHash}; +use ssz::{BitList, H256, SszHash}; use std::{collections::HashMap, fs::File}; use std::{panic::AssertUnwindSafe, path::Path}; use test_generator::test_resources; @@ -60,10 +60,11 @@ impl Into for TestAnchorState { .expect("within limit"); } - let mut justified_slots = JustifiedSlots::new(false, self.justified_slots.data.len()); + let mut justified_slots = + JustifiedSlots(BitList::new(false, self.justified_slots.data.len())); for (i, &val) in self.justified_slots.data.iter().enumerate() { if val { - justified_slots.set(i, true); + justified_slots.0.set(i, true); } } diff --git a/lean_client/fork_choice/tests/unit_tests/fork_choice.rs b/lean_client/fork_choice/tests/unit_tests/fork_choice.rs index ee3f321..597f351 100644 --- a/lean_client/fork_choice/tests/unit_tests/fork_choice.rs +++ b/lean_client/fork_choice/tests/unit_tests/fork_choice.rs @@ -1,6 +1,6 @@ use super::common::create_test_store; use containers::{Block, BlockBody, Slot}; -use fork_choice::store::{get_proposal_head, get_vote_target}; +use fork_choice::store::get_proposal_head; use ssz::{H256, SszHash}; #[test] @@ -49,7 +49,7 @@ fn test_get_vote_target_chain() { // With head at 10 and safe_target at 0: // 1. Walk back 3 slots from head -> 7 // 2. Walk back until justifiable from finalized (0) -> 6 - let target = get_vote_target(&store); + let target = store.get_attestation_target(); assert_eq!(target.slot, Slot(6)); } diff --git a/lean_client/fork_choice/tests/unit_tests/validator.rs b/lean_client/fork_choice/tests/unit_tests/validator.rs index ee9a7b4..876e9b6 100644 --- a/lean_client/fork_choice/tests/unit_tests/validator.rs +++ b/lean_client/fork_choice/tests/unit_tests/validator.rs @@ -9,9 +9,7 @@ use containers::{ Attestation, AttestationData, Block, BlockBody, BlockWithAttestation, Checkpoint, Config, SignatureKey, SignedBlockWithAttestation, Slot, State, Validator, }; -use fork_choice::store::{ - Store, get_forkchoice_store, get_vote_target, produce_block_with_signatures, update_head, -}; +use fork_choice::store::{Store, get_forkchoice_store, produce_block_with_signatures, update_head}; use rand::SeedableRng; use rand_chacha::ChaChaRng; use ssz::{H256, SszHash}; @@ -51,25 +49,6 @@ fn create_test_store_with_signers() -> (Store, HashMap) { (get_forkchoice_store(state, signed_block, config), keys) } - -/// Build AttestationData matching the current store state for a given slot. -/// -/// Equivalent of Python `store.produce_attestation_data(slot)`. -fn produce_attestation_data(store: &Store, slot: Slot) -> AttestationData { - let head_block = &store.blocks[&store.head]; - let head_checkpoint = Checkpoint { - root: store.head, - slot: head_block.slot, - }; - let vote_target = get_vote_target(store); - AttestationData { - slot, - head: head_checkpoint, - target: vote_target, - source: store.latest_justified.clone(), - } -} - // --------------------------------------------------------------------------- // TestBlockProduction // --------------------------------------------------------------------------- @@ -120,7 +99,7 @@ fn test_produce_block_with_attestations() { root: store.head, slot: head_block.slot, }; - let target = get_vote_target(&store); + let target = store.get_attestation_target(); // Add attestations for validators 5 and 6 for vid in [5u64, 6] { @@ -257,7 +236,7 @@ fn test_produce_block_state_consistency() { root: store.head, slot: head_block.slot, }; - let target = get_vote_target(&store); + let target = store.get_attestation_target(); let data = AttestationData { slot: head_block.slot, head: head_checkpoint, @@ -339,7 +318,9 @@ fn test_block_production_then_attestation() { // Other validator creates attestation for slot 2 let attestor_idx = 7; - let attestation_data = produce_attestation_data(&store, Slot(2)); + let attestation_data = store + .produce_attestation_data(Slot(2)) + .expect("failed to produce attestation data"); let attestation = Attestation { validator_id: attestor_idx, data: attestation_data, @@ -366,7 +347,9 @@ fn test_multiple_validators_coordination() { // These will be based on the current forkchoice head (genesis) let mut attestations = Vec::new(); for i in 2..6u64 { - let data = produce_attestation_data(&store, Slot(2)); + let data = store + .produce_attestation_data(Slot(2)) + .expect("failed to produce attestation data"); let attestation = Attestation { validator_id: i, data, @@ -423,7 +406,9 @@ fn test_validator_edge_cases() { assert_eq!(block.proposer_index, max_validator); // Should be able to produce attestation - let attestation_data = produce_attestation_data(&store, Slot(10)); + let attestation_data = store + .produce_attestation_data(Slot(10)) + .expect("failed to produce attestation data"); let attestation = Attestation { validator_id: max_validator, data: attestation_data, @@ -463,7 +448,9 @@ fn test_validator_operations_empty_store() { // Should be able to produce block and attestation let (_root, block, _sig) = produce_block_with_signatures(&mut store, Slot(1), 1).expect("block should succeed"); - let attestation_data = produce_attestation_data(&store, Slot(1)); + let attestation_data = store + .produce_attestation_data(Slot(1)) + .expect("failed to produce attestation data"); let attestation = Attestation { validator_id: 2, data: attestation_data, @@ -532,7 +519,9 @@ fn test_validator_operations_invalid_parameters() { let _: bool = result; // Attestation can be created for any validator - let attestation_data = produce_attestation_data(&store, Slot(1)); + let attestation_data = store + .produce_attestation_data(Slot(1)) + .expect("failed to produce attestation data"); let attestation = Attestation { validator_id: large_validator, data: attestation_data, diff --git a/lean_client/networking/src/gossipsub/config.rs b/lean_client/networking/src/gossipsub/config.rs index 68bb069..56f62d6 100644 --- a/lean_client/networking/src/gossipsub/config.rs +++ b/lean_client/networking/src/gossipsub/config.rs @@ -19,6 +19,9 @@ impl GossipsubConfig { let seen_ttl_secs = seconds_per_slot * justification_lookback_slots * 2; let config = ConfigBuilder::default() + // for now, increased to 16 mb. in the future, should be handled in config + // TODO(p2p): compute transmit size + .max_transmit_size(16 * 1024 * 1024) // leanSpec: heartbeat_interval_secs = 0.7 .heartbeat_interval(Duration::from_millis(700)) // leanSpec: fanout_ttl_secs = 60 diff --git a/lean_client/networking/src/network/service.rs b/lean_client/networking/src/network/service.rs index 0af5417..2874f25 100644 --- a/lean_client/networking/src/network/service.rs +++ b/lean_client/networking/src/network/service.rs @@ -27,7 +27,7 @@ use metrics::{DisconnectReason, METRICS}; use parking_lot::Mutex; use rand::seq::IndexedRandom; use serde::{Deserialize, Serialize}; -use ssz::{H256, SszWrite as _}; +use ssz::{H256, SszHash, SszWrite as _}; use tokio::select; use tokio::time::{Duration, MissedTickBehavior, interval}; use tracing::{debug, info, trace, warn}; @@ -478,6 +478,8 @@ where Event::Message { message, .. } => { match GossipsubMessage::decode(&message.topic, &message.data) { Ok(GossipsubMessage::Block(signed_block_with_attestation)) => { + info!(block_root = %signed_block_with_attestation.message.block.hash_tree_root(), "received block via gossip"); + let slot = signed_block_with_attestation.message.block.slot.0; if let Err(err) = self @@ -495,6 +497,11 @@ where } } Ok(GossipsubMessage::Attestation(signed_attestation)) => { + info!( + validator = %signed_attestation.validator_id, + slot = %signed_attestation.message.slot.0, + "received attestation via gossip" + ); let slot = signed_attestation.message.slot.0; if let Err(err) = self diff --git a/lean_client/src/main.rs b/lean_client/src/main.rs index c99ea35..949aadc 100644 --- a/lean_client/src/main.rs +++ b/lean_client/src/main.rs @@ -18,10 +18,10 @@ use networking::gossipsub::topic::get_topics; use networking::network::{NetworkService, NetworkServiceConfig}; use networking::types::{ChainMessage, OutboundP2pRequest}; use ssz::{PersistentList, SszHash}; -use std::net::IpAddr; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use std::{io::IsTerminal, net::IpAddr}; use tokio::{ sync::mpsc, task, @@ -141,6 +141,7 @@ struct Args { #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() + .with_ansi(std::io::stdout().is_terminal()) .with_env_filter( tracing_subscriber::EnvFilter::builder() .with_default_directive(LevelFilter::INFO.into()) diff --git a/lean_client/validator/Cargo.toml b/lean_client/validator/Cargo.toml index e819550..6d39568 100644 --- a/lean_client/validator/Cargo.toml +++ b/lean_client/validator/Cargo.toml @@ -12,6 +12,7 @@ metrics = { workspace = true } serde_yaml = { workspace = true } ssz = { workspace = true } tracing = { workspace = true } +try_from_iterator = { workspace = true } typenum = { workspace = true } xmss = { workspace = true } zeroize = { workspace = true } diff --git a/lean_client/validator/src/lib.rs b/lean_client/validator/src/lib.rs index 17fe3b4..6d85891 100644 --- a/lean_client/validator/src/lib.rs +++ b/lean_client/validator/src/lib.rs @@ -2,16 +2,18 @@ use std::collections::HashMap; use std::path::Path; -use anyhow::{Context, Result, anyhow, bail, ensure}; +use anyhow::{Context, Result, anyhow, bail}; use containers::{ - Attestation, AttestationData, BlockSignatures, BlockWithAttestation, Checkpoint, - SignedAttestation, SignedBlockWithAttestation, Slot, + AggregatedSignatureProof, Attestation, AttestationData, AttestationSignatures, Block, + BlockSignatures, BlockWithAttestation, Checkpoint, SignedAttestation, + SignedBlockWithAttestation, Slot, }; use ethereum_types::H256; -use fork_choice::store::{Store, get_proposal_head, get_vote_target}; -use metrics::METRICS; +use fork_choice::store::{Store, get_proposal_head, produce_block_with_signatures}; +use metrics::{METRICS, stop_and_discard, stop_and_record}; use ssz::SszHash; use tracing::{info, warn}; +use try_from_iterator::TryFromIterator as _; pub mod keys; @@ -137,230 +139,66 @@ impl ValidatorService { "Building block proposal" ); - let parent_root = get_proposal_head(store, slot); + let (_, block, signatures) = produce_block_with_signatures(store, slot, proposer_index) + .context("failed to produce block")?; - info!( - parent_root = %format!("0x{:x}", parent_root), - store_head = %format!("0x{:x}", store.head), - "Using parent root for block proposal" - ); - - let parent_state = store - .states - .get(&parent_root) - .ok_or_else(|| anyhow!("Couldn't find parent state {:?}", parent_root))?; + let signed_block = self.sign_block(store, block, proposer_index, signatures)?; - let vote_target = get_vote_target(store); - - // Validate that target slot is greater than or equal to source slot - // At genesis, both target and source are slot 0, which is valid - ensure!( - vote_target.slot >= store.latest_justified.slot, - "Invalid attestation: target slot {} must be >= source slot {}", - vote_target.slot.0, - store.latest_justified.slot.0 - ); + Ok(signed_block) + } - let head_block = store - .blocks - .get(&store.head) - .ok_or(anyhow!("Head block not found"))?; - let head_checkpoint = Checkpoint { - root: store.head, - slot: head_block.slot, - }; + fn sign_block( + &self, + store: &Store, + block: Block, + validator_index: u64, + attestation_signatures: Vec, + ) -> Result { + let proposer_attestation_data = store.produce_attestation_data(block.slot)?; let proposer_attestation = Attestation { - validator_id: proposer_index, - data: AttestationData { - slot, - head: head_checkpoint, - target: vote_target.clone(), - source: store.latest_justified.clone(), - }, + validator_id: validator_index, + data: proposer_attestation_data, }; - // Collect valid attestations from the KNOWN attestations pool. - // Note: get_proposal_head() calls accept_new_attestations() which moves attestations - // from latest_new_attestations to latest_known_attestations. So we must read from - // latest_known_attestations here, not latest_new_attestations. - // Filter to only include attestations that: - // 1. Have source matching the parent state's justified checkpoint - // 2. Have target slot > source slot (valid attestations) - // 3. Target block must be known - // 4. Target is not already justified in parent state - // 5. Source is justified in parent state - - // Helper: check if a slot is justified using RELATIVE indexing - // Slots at or before finalized_slot are implicitly justified - let finalized_slot = parent_state.latest_finalized.slot.0 as i64; - let is_slot_justified = |slot: Slot| -> bool { - if (slot.0 as i64) <= finalized_slot { - return true; // Implicitly justified (at or before finalized) - } - let relative_index = (slot.0 as i64 - finalized_slot - 1) as usize; - parent_state - .justified_slots - .get(relative_index) - .map(|b| *b) - .unwrap_or(false) + let Some(key_manager) = self.key_manager.as_ref() else { + bail!("unable to sign block - keymanager not configured"); }; - let valid_attestations: Vec = store - .latest_known_attestations - .iter() - .filter(|(_, data)| { - // Source must match the store's justified checkpoint - // (attestations are created with store.latest_justified as source) - let source_matches = data.source == store.latest_justified; - // Target must be strictly after source - let target_after_source = data.target.slot > data.source.slot; - // Target block must be known - let target_known = store.blocks.contains_key(&data.target.root); - - // Check if target is NOT already justified (using relative indexing) - let target_already_justified = is_slot_justified(data.target.slot); - - // Check if source is justified (using relative indexing) - let source_is_justified = is_slot_justified(data.source.slot); - - source_matches - && target_after_source - && target_known - && source_is_justified - && !target_already_justified - }) - .map(|(validator_idx, data)| Attestation { - validator_id: *validator_idx, - data: data.clone(), - }) - .collect(); - - // De-duplicate by target slot: only include ONE aggregated attestation per target slot. - // This prevents the case where the first attestation justifies a slot and the second - // gets rejected (causing state root mismatch). - // Group by target slot, keeping attestations with the most common AttestationData. - use std::collections::HashMap; - - // First group by target slot - let mut target_slot_groups: HashMap> = HashMap::new(); - for att in valid_attestations { - let target_slot = att.data.target.slot.0; - target_slot_groups.entry(target_slot).or_default().push(att); - } - - // For each target slot, group by data root and pick the one with most votes - let valid_attestations: Vec = target_slot_groups - .into_iter() - .flat_map(|(_, slot_atts)| { - // Group by data root (Bytes32 implements Hash) - let mut data_groups: HashMap> = HashMap::new(); - for att in slot_atts { - let data_root = att.data.hash_tree_root(); - data_groups.entry(data_root).or_default().push(att); - } - // Find the data with the most attestations - data_groups - .into_iter() - .max_by_key(|(_, atts)| atts.len()) - .map(|(_, atts)| atts) - .unwrap_or_default() - }) - .collect(); - - let num_attestations = valid_attestations.len(); - - info!( - slot = slot.0, - valid_attestations = num_attestations, - total_known = store.latest_known_attestations.len(), - "Collected attestations for block" - ); - - // Build block with collected attestations - // Pass gossip_signatures and aggregated_payloads from the store so that - // compute_aggregated_signatures can find signatures for the attestations - let (block, _post_state, _collected_atts, sigs) = { - parent_state.build_block( - slot, - proposer_index, - parent_root, - Some(valid_attestations), - None, // available_attestations - None, // known_block_roots - Some(&store.gossip_signatures), // gossip_signatures - Some(&store.aggregated_payloads), // aggregated_payloads - )? - }; - - let signatures = sigs; - - info!( - slot = block.slot.0, - proposer = block.proposer_index, - parent_root = %format!("0x{:x}", block.parent_root), - state_root = %format!("0x{:x}", block.state_root), - attestation_sigs = num_attestations, - "Block built successfully" - ); - - // Sign the proposer attestation - let proposer_signature: Signature; - - if let Some(ref key_manager) = self.key_manager { - let _timer = METRICS.get().map(|metrics| { + let proposer_signature = { + let sign_timer = METRICS.get().map(|metrics| { metrics .lean_pq_signature_attestation_signing_time_seconds .start_timer() }); - // Sign proposer attestation with XMSS - let message = proposer_attestation.hash_tree_root(); - let epoch = slot.0 as u32; - - match key_manager.sign(proposer_index, epoch, message) { - Ok(sig) => { - proposer_signature = sig; - info!(proposer = proposer_index, "Signed proposer attestation"); - } - Err(e) => { - bail!("Failed to sign proposer attestation: {}", e); - } - } - } else { - // No key manager - use zero signature - warn!("Building block with zero signature (no key manager)"); - proposer_signature = Signature::default(); - } + key_manager + .sign( + validator_index, + block.slot.0 as u32, + proposer_attestation.data.hash_tree_root(), + ) + .context("failed to sign block") + .inspect_err(|_| stop_and_discard(sign_timer))? + }; - // Convert signatures to PersistentList for BlockSignatures - // Extract proof_data from AggregatedSignatureProof for wire format - let attestation_signatures = { - let mut list = ssz::PersistentList::default(); - for proof in signatures { - list.push(proof) - .context("Failed to add attestation signature")?; - } - list + let message = BlockWithAttestation { + block, + proposer_attestation, }; - let signed_block = SignedBlockWithAttestation { - message: BlockWithAttestation { - block, - proposer_attestation, - }, - signature: BlockSignatures { - attestation_signatures, - proposer_signature, - }, + let signature = BlockSignatures { + attestation_signatures: AttestationSignatures::try_from_iter(attestation_signatures) + .context("invalid attestation signatures")?, + proposer_signature, }; - Ok(signed_block) + Ok(SignedBlockWithAttestation { message, signature }) } /// Create attestations for all our validators for the given slot pub fn create_attestations(&self, store: &Store, slot: Slot) -> Vec { - let vote_target = get_vote_target(store); + let vote_target = store.get_attestation_target(); // Skip attestation creation if target slot is less than source slot // At genesis, both target and source are slot 0, which is valid @@ -408,6 +246,11 @@ impl ValidatorService { let message = attestation.hash_tree_root(); let epoch = slot.0 as u32; + let _timer = METRICS.get().map(|metrics| { + metrics + .lean_pq_signature_attestation_signing_time_seconds + .start_timer() + }); match key_manager.sign(idx, epoch, message) { Ok(sig) => { info!( diff --git a/lean_client/xmss/src/aggregated_signature.rs b/lean_client/xmss/src/aggregated_signature.rs index e549991..be587d0 100644 --- a/lean_client/xmss/src/aggregated_signature.rs +++ b/lean_client/xmss/src/aggregated_signature.rs @@ -11,7 +11,7 @@ use lean_multisig::{ }; use metrics::{METRICS, stop_and_discard}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; -use ssz::{ByteList, Ssz}; +use ssz::{ByteList, Ssz, SszRead}; use typenum::U1048576; /// Max size currently is 1MiB by spec. @@ -27,6 +27,7 @@ type AggregatedSignatureSizeLimit = U1048576; /// todo(xmss): deriving Ssz not particularly good there, as this won't validate /// if it actually has valid proof structure, so `.as_lean()` method may panic. #[derive(Debug, Clone, Ssz)] +#[ssz(transparent)] pub struct AggregatedSignature(ByteList); fn setup_prover() { diff --git a/lean_client/xmss/src/public_key.rs b/lean_client/xmss/src/public_key.rs index fce7629..d946e48 100644 --- a/lean_client/xmss/src/public_key.rs +++ b/lean_client/xmss/src/public_key.rs @@ -7,17 +7,54 @@ use core::{ use anyhow::{Error, anyhow}; use leansig::{serialization::Serializable, signature::SignatureScheme, signature::generalized_xmss::instantiations_poseidon_top_level::lifetime_2_to_the_32::hashing_optimized::SIGTopLevelTargetSumLifetime32Dim64Base8}; use serde::{Deserialize, Serialize, de::{self, Visitor}}; -use ssz::{ByteVector, Ssz}; +use ssz::{BytesToDepth, MerkleTree, SszHash, SszRead, SszSize, SszWrite}; use eth_ssz::DecodeError; -use typenum::U52; +use typenum::{U52, U1, Unsigned}; type PublicKeySize = U52; type LeanSigPublicKey = ::PublicKey; +#[derive(Clone, PartialEq, Eq)] +pub struct PublicKey([u8; PublicKeySize::USIZE]); + // todo(xmss): default implementation doesn't make sense here -#[derive(Clone, Ssz, Default, PartialEq, Eq)] -pub struct PublicKey(ByteVector); +impl Default for PublicKey { + fn default() -> Self { + Self([0u8; PublicKeySize::USIZE]) + } +} + +impl SszSize for PublicKey { + const SIZE: ssz::Size = ssz::Size::Fixed { + size: PublicKeySize::USIZE, + }; +} + +impl SszRead for PublicKey { + #[inline] + fn from_ssz_unchecked(_context: &C, bytes: &[u8]) -> Result { + Ok(Self( + bytes.try_into().expect("byte length should be checked"), + )) + } +} + +impl SszWrite for PublicKey { + #[inline] + fn write_fixed(&self, bytes: &mut [u8]) { + bytes.copy_from_slice(&self.0); + } +} + +impl SszHash for PublicKey { + type PackingFactor = U1; + + #[inline] + fn hash_tree_root(&self) -> ssz::H256 { + MerkleTree::>::merkleize_bytes(self.0) + } +} impl PublicKey { pub fn new(bytes: &[u8]) -> Result { @@ -38,20 +75,19 @@ impl PublicKey { } pub(crate) fn as_lean(&self) -> LeanSigPublicKey { - LeanSigPublicKey::from_bytes(self.0.as_bytes()) - .expect("PublicKey was instantiated incorrectly") + LeanSigPublicKey::from_bytes(&self.0).expect("PublicKey was instantiated incorrectly") } } impl Debug for PublicKey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "0x{}", hex::encode(self.0.as_bytes())) + write!(f, "0x{}", hex::encode(&self.0)) } } impl Display for PublicKey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "0x{}", hex::encode(self.0.as_bytes())) + write!(f, "0x{}", hex::encode(&self.0)) } } diff --git a/lean_client/xmss/src/signature.rs b/lean_client/xmss/src/signature.rs index 3467ece..ab0085f 100644 --- a/lean_client/xmss/src/signature.rs +++ b/lean_client/xmss/src/signature.rs @@ -21,7 +21,8 @@ type SignatureSize = U3112; type LeanSigSignature = ::Signature; // todo(xmss): default implementation doesn't make sense here, and is needed only for tests -#[derive(Ssz, Clone, Default)] +#[derive(Clone, Default, Ssz)] +#[ssz(transparent)] pub struct Signature(ByteVector); impl Signature {