diff --git a/lean_client/containers/src/attestation.rs b/lean_client/containers/src/attestation.rs index 9c3537c..b4f5c9a 100644 --- a/lean_client/containers/src/attestation.rs +++ b/lean_client/containers/src/attestation.rs @@ -157,17 +157,26 @@ impl AggregatedAttestation { }) .collect() } +} - pub fn to_plain(&self) -> Vec { - let validator_indices = self.aggregation_bits.to_validator_indices(); +/// Trait for checking duplicate attestation data. +pub trait HasDuplicateData { + /// Returns true if the list contains duplicate AttestationData. + fn has_duplicate_data(&self) -> bool; +} - validator_indices - .into_iter() - .map(|validator_id| Attestation { - validator_id: Uint64(validator_id), - data: self.data.clone(), - }) - .collect() +impl HasDuplicateData for AggregatedAttestations { + fn has_duplicate_data(&self) -> bool { + use ssz::SszHash; + use std::collections::HashSet; + let mut seen: HashSet = HashSet::new(); + for attestation in self { + let root = attestation.data.hash_tree_root(); + if !seen.insert(root) { + return true; + } + } + false } } diff --git a/lean_client/containers/src/state.rs b/lean_client/containers/src/state.rs index b176e55..d6c7cf8 100644 --- a/lean_client/containers/src/state.rs +++ b/lean_client/containers/src/state.rs @@ -8,7 +8,7 @@ use ssz::{PersistentList as List, PersistentList}; use ssz_derive::Ssz; use std::collections::BTreeMap; use typenum::U4096; -use crate::attestation::AggregatedAttestations; +use crate::attestation::{AggregatedAttestations, HasDuplicateData}; use crate::block::BlockSignatures; pub const VALIDATOR_REGISTRY_LIMIT: usize = 1 << 12; // 4096 @@ -296,24 +296,13 @@ impl State { pub fn process_block(&self, block: &Block) -> Result { let state = self.process_block_header(block)?; - #[cfg(feature = "devnet1")] - let state_after_ops = state.process_attestations(&block.body.attestations); + #[cfg(feature = "devnet2")] - let state_after_ops = { - let mut unaggregated_attestations = Attestations::default(); - for aggregated_attestation in &block.body.attestations { - let plain_attestations = aggregated_attestation.to_plain(); - // For each attestatio in the vector, push to the list - for attestation in plain_attestations { - unaggregated_attestations.push(attestation).map_err(|e| format!("Failed to push attestation: {:?}", e))?; - } - } - state.process_attestations(&unaggregated_attestations) - }; - - // State root validation is handled by state_transition_with_validation when needed + if block.body.attestations.has_duplicate_data() { + return Err("Block contains duplicate AttestationData".to_string()); + } - Ok(state_after_ops) + Ok(state.process_attestations(&block.body.attestations)) } pub fn process_block_header(&self, block: &Block) -> Result { @@ -403,16 +392,14 @@ impl State { }) } + #[cfg(feature = "devnet1")] pub fn process_attestations(&self, attestations: &Attestations) -> Self { let mut justifications = self.get_justifications(); let mut latest_justified = self.latest_justified.clone(); let mut latest_finalized = self.latest_finalized.clone(); - // Store initial finalized slot for justifiability checks (per leanSpec) let initial_finalized_slot = self.latest_finalized.slot; let justified_slots = self.justified_slots.clone(); - // PersistentList doesn't expose iter; convert to Vec for simple iteration for now - // Build a temporary Vec by probing sequentially until index error let mut votes_vec: Vec = Vec::new(); let mut i: u64 = 0; loop { @@ -423,116 +410,142 @@ impl State { i += 1; } - // Create mutable working BitList for justified_slots tracking 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)); } for attestation in votes_vec.iter() { - let vote = attestation.data.clone(); - 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 target_slot_int = target_slot.0 as usize; - let source_slot_int = source_slot.0 as usize; - - let source_is_justified = justified_slots_working - .get(source_slot_int) - .copied() - .unwrap_or(false); - let target_already_justified = justified_slots_working - .get(target_slot_int) - .copied() - .unwrap_or(false); - - let source_root_matches_history = self - .historical_block_hashes - .get(source_slot_int as u64) - .map(|root| *root == source_root) - .unwrap_or(false); - - let target_root_matches_history = self - .historical_block_hashes - .get(target_slot_int as u64) - .map(|root| *root == target_root) - .unwrap_or(false); - - let target_is_after_source = target_slot > source_slot; - // Use initial_finalized_slot per leanSpec (not the mutating local copy) - let target_is_justifiable = target_slot.is_justifiable_after(initial_finalized_slot); - - // leanSpec logic: skip if BOTH source and target roots don't match history - // i.e., continue if EITHER matches - let roots_valid = source_root_matches_history || target_root_matches_history; - - let is_valid_vote = source_is_justified - && !target_already_justified - && roots_valid - && target_is_after_source - && target_is_justifiable; - - if !is_valid_vote { - continue; - } + self.process_single_attestation( + &attestation.data, + &[attestation.validator_id.0], + &mut justifications, + &mut latest_justified, + &mut latest_finalized, + &mut justified_slots_working, + initial_finalized_slot, + ); + } - if !justifications.contains_key(&target_root) { - // Use actual validator count, not VALIDATOR_REGISTRY_LIMIT - // This matches leanSpec: justifications[target.root] = [Boolean(False)] * self.validators.count - let num_validators = self.validator_count(); - justifications.insert(target_root, vec![false; num_validators]); - } + self.finalize_attestation_processing(justifications, latest_justified, latest_finalized, justified_slots_working) + } - let validator_id = attestation.validator_id.0 as usize; - if let Some(votes) = justifications.get_mut(&target_root) { - if validator_id < votes.len() && !votes[validator_id] { - votes[validator_id] = true; + #[cfg(feature = "devnet2")] + pub fn process_attestations(&self, attestations: &AggregatedAttestations) -> Self { + 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(); + + 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)); + } + + for aggregated_attestation in attestations { + 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, + ); + } - let num_validators = self.validators.len_u64(); + self.finalize_attestation_processing(justifications, latest_justified, latest_finalized, justified_slots_working) + } - let count = votes.iter().filter(|&&v| v).count(); - if 3 * count >= 2 * num_validators as usize { - latest_justified = vote.target; + /// Process a single attestation's votes. + fn process_single_attestation( + &self, + vote: &crate::attestation::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 target_slot_int = target_slot.0 as usize; + let source_slot_int = source_slot.0 as usize; + + let source_is_justified = justified_slots_working.get(source_slot_int).copied().unwrap_or(false); + let target_already_justified = justified_slots_working.get(target_slot_int).copied().unwrap_or(false); + + 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); + + 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); + + if !is_valid_vote { + return; + } - // Extend justified_slots_working if needed - while justified_slots_working.len() <= target_slot_int { - justified_slots_working.push(false); - } - justified_slots_working[target_slot_int] = true; + if !justifications.contains_key(&target_root) { + justifications.insert(target_root, vec![false; self.validator_count()]); + } - justifications.remove(&target_root); + 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; + } + } + } - let mut is_finalizable = true; - for s in (source_slot_int + 1)..target_slot_int { - // Use initial_finalized_slot per leanSpec - if Slot(s as u64).is_justifiable_after(initial_finalized_slot) { - is_finalizable = false; - break; - } - } + 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(); + if 3 * count >= 2 * num_validators { + *latest_justified = vote.target.clone(); - if is_finalizable { - latest_finalized = vote.source; - } - } + while justified_slots_working.len() <= target_slot_int { + justified_slots_working.push(false); + } + justified_slots_working[target_slot_int] = true; + + justifications.remove(&target_root); + + let is_finalizable = (source_slot_int + 1..target_slot_int) + .all(|s| !Slot(s as u64).is_justifiable_after(initial_finalized_slot)); + + if is_finalizable { + *latest_finalized = vote.source.clone(); } } } + } + 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; - // Convert justified_slots_working Vec back to BitList 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 } diff --git a/lean_client/containers/tests/unit_tests/state_transition.rs b/lean_client/containers/tests/unit_tests/state_transition.rs index 9fe6abb..a18ac61 100644 --- a/lean_client/containers/tests/unit_tests/state_transition.rs +++ b/lean_client/containers/tests/unit_tests/state_transition.rs @@ -3,7 +3,7 @@ use containers::{ block::{hash_tree_root, Block, BlockWithAttestation, SignedBlockWithAttestation}, state::State, types::{Bytes32, Uint64}, - Attestation, Attestations, Slot, + Attestation, Slot, }; use pretty_assertions::assert_eq; use rstest::fixture; @@ -31,22 +31,8 @@ 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(); - #[cfg(feature = "devnet1")] let expected_state = state_after_header.process_attestations(&block.body.attestations); - #[cfg(feature = "devnet2")] - let expected_state = { - let mut unaggregated_attestations = Attestations::default(); - for aggregated_attestation in &block.body.attestations { - let plain_attestations = aggregated_attestation.to_plain(); - // For each attestatio in the vector, push to the list - for attestation in plain_attestations { - unaggregated_attestations.push(attestation); - } - } - state_after_header.process_attestations(&unaggregated_attestations) - }; - let block_with_correct_root = Block { state_root: hash_tree_root(&expected_state), ..block @@ -79,22 +65,8 @@ 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(); - #[cfg(feature = "devnet1")] let expected_state = state_after_header.process_attestations(&block.body.attestations); - #[cfg(feature = "devnet2")] - let expected_state = { - let mut list = Attestations::default(); - for aggregated_attestation in &block.body.attestations { - let plain_attestations = aggregated_attestation.to_plain(); - // For each attestatio in the vector, push to the list - for attestation in plain_attestations { - list.push(attestation); - } - } - list - }; - let block_with_correct_root = Block { state_root: hash_tree_root(&expected_state), ..block @@ -152,21 +124,7 @@ fn test_state_transition_devnet2() { // Process the block header and attestations let state_after_header = state_at_slot_1.process_block_header(&block).unwrap(); - #[cfg(feature = "devnet1")] let expected_state = state_after_header.process_attestations(&block.body.attestations); - - #[cfg(feature = "devnet2")] - let expected_state = { - let mut unaggregated_attestations = Attestations::default(); - for aggregated_attestation in &block.body.attestations { - let plain_attestations = aggregated_attestation.to_plain(); - // For each attestatio in the vector, push to the list - for attestation in plain_attestations { - unaggregated_attestations.push(attestation); - } - } - state_after_header.process_attestations(&unaggregated_attestations) - }; // 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 b16f561..badc834 100644 --- a/lean_client/fork_choice/Cargo.toml +++ b/lean_client/fork_choice/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" [features] default = [] devnet1 = ["containers/devnet1", "env-config/devnet1"] -devnet2 = ["containers/devnet2", "env-config/devnet1"] +devnet2 = ["containers/devnet2", "env-config/devnet2"] [dependencies] env-config = { path = "../env-config", default-features = false } diff --git a/lean_client/networking/Cargo.toml b/lean_client/networking/Cargo.toml index 8f47702..0584a0e 100644 --- a/lean_client/networking/Cargo.toml +++ b/lean_client/networking/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" [features] default = [] devnet1 = ["containers/devnet1", "env-config/devnet1"] -devnet2 = ["containers/devnet2", "env-config/devnet1"] +devnet2 = ["containers/devnet2", "env-config/devnet2"] [dependencies] env-config = { path = "../env-config", default-features = false } diff --git a/lean_client/validator/Cargo.toml b/lean_client/validator/Cargo.toml index 8311b9d..ab09109 100644 --- a/lean_client/validator/Cargo.toml +++ b/lean_client/validator/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" default = ["xmss-signing"] xmss-signing = ["leansig"] devnet1 = ["containers/devnet1", "fork-choice/devnet1", "env-config/devnet1"] -devnet2 = ["containers/devnet2", "fork-choice/devnet2", "env-config/devnet1"] +devnet2 = ["containers/devnet2", "fork-choice/devnet2", "env-config/devnet2"] [dependencies] env-config = { path = "../env-config", default-features = false }