diff --git a/crates/blockchain/state_transition/src/beacon/block_production.rs b/crates/blockchain/state_transition/src/beacon/block_production.rs index 0c412cb4..b0230d4d 100644 --- a/crates/blockchain/state_transition/src/beacon/block_production.rs +++ b/crates/blockchain/state_transition/src/beacon/block_production.rs @@ -344,6 +344,10 @@ pub fn assemble_block( &ExecutionEngine::valid(), &CommitteeCache::default(), )?; + // Fold the block's writes into their trees first, so the root is computed + // on (and cached in) the state's own nodes rather than a throwaway copy, + // as `state_transition` does before it checks a block's state root. + post.apply_pending_mutations(); block.state_root = post.hash_tree_root(); Ok(block) } diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs index 1b2f0732..90cdce59 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs @@ -136,8 +136,8 @@ pub fn process_inactivity_updates(state: &mut BeaconState, config: &Config) -> R let (_, _, inactivity_scores) = state.altair_validator_lists_mut()?; let score_count = inactivity_scores.len(); for index in eligible_indices { - let score = inactivity_scores - .get_mut(index as usize) + let score = *inactivity_scores + .get(index as usize) .ok_or(Error::IndexOutOfBounds { index: index as usize, len: score_count, @@ -146,21 +146,30 @@ pub fn process_inactivity_updates(state: &mut BeaconState, config: &Config) -> R // `participating_indices` is ascending and duplicate-free (see // `get_unslashed_participating_indices`), so membership is a binary // search rather than a linear scan. - if participating_indices.binary_search(&index).is_ok() { + let mut new_score = if participating_indices.binary_search(&index).is_ok() { // `x -= min(1, x)`, written with `saturating_sub` so a // already-zero score cannot underflow. - *score = saturating_sub(*score, 1); + saturating_sub(score, 1) } else { // The specification treats a `uint64` overflow here as an invalid // state rather than a wrapped one, so this is checked rather than // left to release-mode wrapping. - *score = score.checked_add(config.inactivity_score_bias).ok_or( - Error::ArithmeticOverflow("inactivity_scores[index] + INACTIVITY_SCORE_BIAS"), - )?; - } + score + .checked_add(config.inactivity_score_bias) + .ok_or(Error::ArithmeticOverflow( + "inactivity_scores[index] + INACTIVITY_SCORE_BIAS", + ))? + }; if !leaking { - *score = saturating_sub(*score, config.inactivity_score_recovery_rate); + new_score = saturating_sub(new_score, config.inactivity_score_recovery_rate); + } + + // Outside a leak almost every score stays zero. A write rebuilds its + // leaf even for an equal value, which would unshare the whole list + // from the parent state's, so only a changed score is written. + if new_score != score { + inactivity_scores[index as usize] = new_score; } } @@ -405,6 +414,57 @@ mod tests { assert_eq!(scores[0], 0); } + /// Outside a leak a missed epoch adds the bias and recovery takes it back + /// to zero, so every score ends where it started. The pass must then + /// leave the list untouched, not just equal: a write of an equal value + /// would still unshare the tree from the parent state's. + #[test] + fn inactivity_updates_that_change_nothing_leave_the_tree_shared() { + let mut state = altair_state_with_validators(4); + state.apply_pending_mutations(); + let before = state.altair_validator_lists().unwrap().2.clone(); + + process_inactivity_updates(&mut state, &Config::mainnet()).unwrap(); + + let (_, _, scores) = state.altair_validator_lists().unwrap(); + assert!(!scores.has_pending_updates(), "no write was buffered"); + assert!(scores.ptr_eq(&before)); + assert_eq!(scores.to_vec(), vec![0; 4]); + } + + #[test] + fn inactivity_updates_buffer_only_the_scores_that_change() { + let mut state = altair_state_with_validators(4); + { + let (previous_epoch_participation, _, scores) = + state.altair_validator_lists_mut().unwrap(); + scores[2] = 3; + // Validator 2 participates, so its score falls to zero; the rest + // stay at zero. + previous_epoch_participation[2] = add_flag(0, constants::TIMELY_TARGET_FLAG_INDEX); + } + state.apply_pending_mutations(); + + process_inactivity_updates(&mut state, &Config::mainnet()).unwrap(); + + let (_, _, scores) = state.altair_validator_lists().unwrap(); + assert!(scores.has_pending_updates()); + assert_eq!(scores.to_vec(), vec![0; 4]); + } + + #[test] + fn inactivity_updates_still_reject_an_overflowing_score() { + let mut state = altair_state_with_validators(4); + { + let (_, _, scores) = state.altair_validator_lists_mut().unwrap(); + scores[1] = u64::MAX; + } + + let result = process_inactivity_updates(&mut state, &Config::mainnet()); + + assert!(matches!(result, Err(Error::ArithmeticOverflow(_)))); + } + // ----------------------------------------------------------------------- // process_rewards_and_penalties // ----------------------------------------------------------------------- diff --git a/crates/blockchain/state_transition/src/beacon/stf/mod.rs b/crates/blockchain/state_transition/src/beacon/stf/mod.rs index 74367286..8f91a92d 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/mod.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/mod.rs @@ -317,7 +317,9 @@ pub(crate) fn phase0_state_ref<'a>( #[cfg(test)] mod tests { use super::*; + use crate::beacon::fork::ForkName; use crate::beacon::helpers::test_state; + use crate::beacon::primitives::Root; #[test] fn process_slot_leaves_no_buffered_registry_writes() { @@ -331,6 +333,10 @@ mod tests { assert!(!state.balances().has_pending_updates()); assert!(!state.validators().has_pending_updates()); + // Its own two roots writes stay buffered until the next flush, which + // the following slot (or `process_slots`' last step) performs. + assert!(state.state_roots().has_pending_updates()); + assert!(state.block_roots().has_pending_updates()); } /// Epoch processing (`process_rewards_and_penalties` here) writes every @@ -347,7 +353,109 @@ mod tests { process_slots(&mut state, target_slot, &config).unwrap(); - assert!(!state.balances().has_pending_updates()); - assert!(!state.validators().has_pending_updates()); + assert!(!state.has_pending_mutations()); + } + + #[test] + fn process_slots_flushes_the_roots_it_wrote_in_its_last_slot() { + for fork in BEACON_FORKS { + let mut state = test_state::with_validators_at(fork, 4); + let target_slot = state.slot() + 1; + + process_slots(&mut state, target_slot, &Config::mainnet()).unwrap(); + + assert!(!state.has_pending_mutations(), "{fork:?}"); + } + } + + const BEACON_FORKS: [ForkName; 7] = [ + ForkName::Phase0, + ForkName::Altair, + ForkName::Bellatrix, + ForkName::Capella, + ForkName::Deneb, + ForkName::Electra, + ForkName::Fulu, + ]; + + /// Writes one element of the field a case names, or answers `false` if the + /// fork does not have it. + type Write = fn(&mut BeaconState) -> bool; + + fn push_historical_summary(state: &mut BeaconState) -> bool { + let summary = containers::HistoricalSummary::default(); + match state { + BeaconState::Capella(s) => s.historical_summaries.push(summary).unwrap(), + BeaconState::Deneb(s) => s.historical_summaries.push(summary).unwrap(), + BeaconState::Electra(s) => s.historical_summaries.push(summary).unwrap(), + BeaconState::Fulu(s) => s.historical_summaries.push(summary).unwrap(), + _ => return false, + } + true + } + + /// One write to every tree-backed field of every fork: the flush and the + /// pending check must both see all of them, since both come from one + /// field list. + #[test] + fn every_tree_field_of_every_fork_is_flushed_and_checked() { + let cases: [(&str, Write); 11] = [ + ("validators", |s| { + s.validator_mut(0).unwrap().effective_balance -= 1; + true + }), + ("balances", |s| { + s.balances_mut()[0] += 1; + true + }), + ("block_roots", |s| { + s.block_roots_mut()[0] = Root::repeat_byte(1); + true + }), + ("state_roots", |s| { + s.state_roots_mut()[0] = Root::repeat_byte(1); + true + }), + ("historical_roots", |s| { + s.historical_roots_mut().push(Root::repeat_byte(1)).unwrap(); + true + }), + ("eth1_data_votes", |s| { + s.eth1_data_votes_mut().push(Default::default()).unwrap(); + true + }), + ("randao_mixes", |s| { + s.randao_mixes_mut()[0] = Root::repeat_byte(1); + true + }), + ("slashings", |s| { + s.slashings_mut()[0] += 1; + true + }), + ("inactivity_scores", |s| { + match s.altair_validator_lists_mut() { + Ok((_, _, scores)) => { + scores[0] += 1; + true + } + Err(_) => false, + } + }), + ("historical_summaries", push_historical_summary), + // A read-only pass writes nothing and must leave nothing pending. + ("none", |_| false), + ]; + + for fork in BEACON_FORKS { + let mut state = test_state::with_validators_at(fork, 4); + assert!(!state.has_pending_mutations(), "{fork:?} starts flushed"); + + for (name, write) in cases { + let wrote = write(&mut state); + assert_eq!(state.has_pending_mutations(), wrote, "{fork:?} {name}"); + state.apply_pending_mutations(); + assert!(!state.has_pending_mutations(), "{fork:?} {name} flushed"); + } + } } } diff --git a/crates/blockchain/state_transition/src/beacon/upgrade.rs b/crates/blockchain/state_transition/src/beacon/upgrade.rs index 8541ae8f..df3a3a88 100644 --- a/crates/blockchain/state_transition/src/beacon/upgrade.rs +++ b/crates/blockchain/state_transition/src/beacon/upgrade.rs @@ -974,8 +974,8 @@ mod tests { // place rather than clearing it, since `historical_summaries` is // where new history accumulates from here on. assert_eq!( - post.historical_roots.into_inner(), - bellatrix_state.historical_roots.into_inner() + post.historical_roots.to_vec(), + bellatrix_state.historical_roots.to_vec() ); assert_eq!(post.next_withdrawal_index, 0); assert_eq!(post.next_withdrawal_validator_index, 0); diff --git a/crates/common/ssz-tree/src/lib.rs b/crates/common/ssz-tree/src/lib.rs index 561be2a9..f105830f 100644 --- a/crates/common/ssz-tree/src/lib.rs +++ b/crates/common/ssz-tree/src/lib.rs @@ -69,6 +69,19 @@ pub use vector::Vector; use libssz::{SszDecode, SszEncode}; use libssz_merkle::HashTreeRoot; +/// The buffered-write surface of a [`List`] or [`Vector`], independent of its +/// element type and update map. +/// +/// Object safe, so a container can hold one `&dyn Buffered` per tree field of +/// different element types and flush or check them all from one field list. +pub trait Buffered { + /// Folds every buffered write into the tree. + fn apply_updates(&mut self); + + /// Whether any write is buffered and not yet folded into the tree. + fn has_pending_updates(&self) -> bool; +} + /// A 32-byte Merkle node. pub(crate) type Hash256 = libssz_merkle::Node; diff --git a/crates/common/ssz-tree/src/list.rs b/crates/common/ssz-tree/src/list.rs index f0f98b6f..fc246d29 100644 --- a/crates/common/ssz-tree/src/list.rs +++ b/crates/common/ssz-tree/src/list.rs @@ -115,6 +115,16 @@ impl> List { } } +impl> crate::Buffered for List { + fn apply_updates(&mut self) { + List::apply_updates(self); + } + + fn has_pending_updates(&self) -> bool { + List::has_pending_updates(self) + } +} + impl> Default for List { fn default() -> Self { Self::empty() diff --git a/crates/common/ssz-tree/src/vector.rs b/crates/common/ssz-tree/src/vector.rs index 0ec13690..9166b380 100644 --- a/crates/common/ssz-tree/src/vector.rs +++ b/crates/common/ssz-tree/src/vector.rs @@ -87,6 +87,16 @@ impl> Vector { } } +impl> crate::Buffered for Vector { + fn apply_updates(&mut self) { + Vector::apply_updates(self); + } + + fn has_pending_updates(&self) -> bool { + Vector::has_pending_updates(self) + } +} + impl> Default for Vector { /// A vector of `N` default-valued elements. fn default() -> Self { diff --git a/crates/common/ssz-tree/tests/model.rs b/crates/common/ssz-tree/tests/model.rs index e09a5f1b..d01d805a 100644 --- a/crates/common/ssz-tree/tests/model.rs +++ b/crates/common/ssz-tree/tests/model.rs @@ -521,3 +521,221 @@ proptest! { rebase_shrunk::>(orig_values, extra, hash_orig_first, hash_base_first)?; } } + +// ── Shapes the beacon state's tree fields take ── + +/// A 32-byte transparent newtype whose `HashTreeRoot` is derived, so it +/// reports a composite (the shape `H256` had before it forwarded the answer). +#[derive(Debug, Clone, Copy, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)] +#[ssz(transparent)] +struct CompositeHash([u8; 32]); + +/// The same newtype with `is_basic_type` forwarded, as `H256` now does. +#[derive(Debug, Clone, Copy, PartialEq, Eq, SszEncode, SszDecode)] +#[ssz(transparent)] +struct BasicHash([u8; 32]); + +impl HashTreeRoot for BasicHash { + fn hash_tree_root(&self, hasher: &impl libssz_merkle::Sha256Hasher) -> libssz_merkle::Node { + HashTreeRoot::hash_tree_root(&self.0, hasher) + } + + fn is_basic_type() -> bool { + <[u8; 32] as HashTreeRoot>::is_basic_type() + } +} + +fn composite_hash() -> impl Strategy + Clone { + prop_oneof![ + Just(CompositeHash([0; 32])), + any::<[u8; 32]>().prop_map(CompositeHash) + ] +} + +fn basic_hash() -> impl Strategy + Clone { + prop_oneof![ + Just(BasicHash([0; 32])), + any::<[u8; 32]>().prop_map(BasicHash) + ] +} + +fn zero_prone_u8() -> impl Strategy + Clone { + prop_oneof![3 => Just(0u8), 1 => any::()] +} + +/// Like `check_rebase`, for vectors, which never change length. +fn rebase_vector( + base_values: Vec, + writes: Vec<(usize, T)>, + hash_base_first: bool, +) -> Result<(), TestCaseError> +where + T: Value + Debug, + U: UpdateMap, +{ + let base = Vector::::try_from(base_values.clone()).unwrap(); + if hash_base_first { + root(&base); + } + let mut model = base_values.clone(); + let mut orig = base.clone(); + for (index, value) in writes { + let index = index % N; + orig[index] = value.clone(); + model[index] = value; + } + orig.apply_updates(); + // A decoded copy shares nothing with `base`, so sharing comes from the rebase. + let bytes = orig.to_ssz(); + let mut orig = Vector::::from_ssz_bytes(&bytes).unwrap(); + + orig.rebase_on(&base); + + let reference = SszVector::::try_from(model.clone()).unwrap(); + prop_assert_eq!(orig.to_vec(), model.clone()); + prop_assert_eq!(root(&orig), root(&reference)); + let base_reference = SszVector::::try_from(base_values.clone()).unwrap(); + prop_assert_eq!(base.to_vec(), base_values.clone()); + prop_assert_eq!(root(&base), root(&base_reference)); + if model == base_values { + prop_assert!(orig.ptr_eq(&base)); + } + Ok(()) +} + +proptest! { + /// A leaf holds 4096 u8s: this spans several, with the last partial. + #[test] + fn u8_list_spanning_leaves( + initial in vec(any::(), 0..10_000), + ops in ops(any::()), + ) { + run_list::>(initial, ops)?; + } + + /// A leaf holds 512 u64s: 8192 slashings span 16. + #[test] + fn u64_vector_spanning_leaves( + initial in vec(any::(), 8192), + writes in vec((any::(), any::(), any::()), 0..40), + ) { + run_vector::>(initial, writes)?; + } + + /// A leaf holds 128 roots: 300 span three. + #[test] + fn composite_hash_vector_spanning_leaves( + initial in vec(composite_hash(), 300), + writes in vec((any::(), composite_hash(), any::()), 0..40), + ) { + run_vector::>(initial, writes)?; + } + + #[test] + fn basic_hash_vector_spanning_leaves( + initial in vec(basic_hash(), 300), + writes in vec((any::(), basic_hash(), any::()), 0..40), + ) { + run_vector::>(initial, writes)?; + } + + #[test] + fn basic_hash_list_spanning_leaves( + initial in vec(basic_hash(), 0..300), + ops in ops(basic_hash()), + ) { + run_list::>(initial, ops)?; + } + + #[test] + fn basic_and_composite_hash_collections_have_one_root( + values in vec(any::<[u8; 32]>(), 0..300), + ) { + let basic = List::::try_from( + values.iter().copied().map(BasicHash).collect::>(), + ).unwrap(); + let composite = List::::try_from( + values.iter().copied().map(CompositeHash).collect::>(), + ).unwrap(); + prop_assert_eq!(root(&basic), root(&composite)); + prop_assert_eq!(basic.to_ssz(), composite.to_ssz()); + } + + #[test] + fn u8_list_decode_parity(bytes in maybe_valid_bytes(vec(any::(), 0..=300))) { + list_decode_parity::(&bytes)?; + } + + #[test] + fn basic_hash_vector_decode_parity(bytes in maybe_valid_bytes(vec(basic_hash(), 0..=9))) { + vector_decode_parity::(&bytes)?; + } + + #[test] + fn u64_vector_decode_parity_multi_leaf(bytes in maybe_valid_bytes(vec(any::(), 0..=1100))) { + vector_decode_parity::(&bytes)?; + } + + // Mostly-zero lists: the trailing-zero trap in `rebase_on`. + #[test] + fn u8_rebase_grown( + base_values in vec(zero_prone_u8(), 0..9000), + ops in ops(zero_prone_u8()), + hash_base_first in any::(), + hash_orig_first in any::(), + ) { + rebase_grown::>(base_values, ops, hash_base_first, hash_orig_first)?; + } + + #[test] + fn u8_rebase_shrunk( + orig_values in vec(zero_prone_u8(), 0..9000), + extra in vec(zero_prone_u8(), 0..300), + hash_orig_first in any::(), + hash_base_first in any::(), + ) { + rebase_shrunk::>(orig_values, extra, hash_orig_first, hash_base_first)?; + } + + #[test] + fn u64_btree_rebase_grown_spanning_leaves( + base_values in vec(zero_prone_u64(), 0..1400), + ops in ops(zero_prone_u64()), + hash_base_first in any::(), + hash_orig_first in any::(), + ) { + rebase_grown::>(base_values, ops, hash_base_first, hash_orig_first)?; + } + + #[test] + fn u64_vector_rebase( + base_values in vec(zero_prone_u64(), 1024), + writes in vec((any::(), zero_prone_u64()), 0..8), + hash_base_first in any::(), + ) { + rebase_vector::>(base_values, writes, hash_base_first)?; + } + + #[test] + fn basic_hash_vector_rebase( + base_values in vec(basic_hash(), 300), + writes in vec((any::(), basic_hash()), 0..8), + hash_base_first in any::(), + ) { + rebase_vector::>(base_values, writes, hash_base_first)?; + } +} + +#[test] +fn buffered_forwards_to_lists_and_vectors() { + use ethlambda_ssz_tree::Buffered; + + let mut list = List::::try_from(vec![1, 2, 3]).unwrap(); + let mut vector = Vector::<[u8; 32], 4>::try_from(vec![[0u8; 32]; 4]).unwrap(); + list[0] = 9; + vector[1] = [7; 32]; + let mut fields: Vec<&mut dyn Buffered> = vec![&mut list, &mut vector]; + assert!(fields.iter().all(|f| f.has_pending_updates())); + fields.iter_mut().for_each(|f| f.apply_updates()); + assert!(fields.iter().all(|f| !f.has_pending_updates())); +} diff --git a/crates/common/types/src/beacon/containers/mod.rs b/crates/common/types/src/beacon/containers/mod.rs index 40a240d9..828f512e 100644 --- a/crates/common/types/src/beacon/containers/mod.rs +++ b/crates/common/types/src/beacon/containers/mod.rs @@ -395,6 +395,57 @@ impl BeaconState { } } +/// Lists the tree-backed fields of one fork's state, once, and derives from +/// that list both the flush (`apply_pending_mutations`) and the pending check +/// (`has_pending_mutations`), so the two cannot drift apart when a field moves +/// onto the tree. +/// +/// The fields are handed out as `dyn Buffered`, since they differ in element +/// type and update map. +macro_rules! tree_fields { + ($fork:ty => $($field:ident),+ $(,)?) => { + impl $fork { + fn buffered(&self) -> [&dyn ethlambda_ssz_tree::Buffered; tree_fields!(@count $($field)+)] { + [$(&self.$field),+] + } + + fn buffered_mut( + &mut self, + ) -> [&mut dyn ethlambda_ssz_tree::Buffered; tree_fields!(@count $($field)+)] { + [$(&mut self.$field),+] + } + } + }; + (@count) => { 0usize }; + (@count $head:ident $($tail:ident)*) => { 1usize + tree_fields!(@count $($tail)*) }; +} + +tree_fields!( + phase0::BeaconState => validators, balances, block_roots, state_roots, historical_roots, eth1_data_votes, randao_mixes, slashings +); +tree_fields!( + altair::BeaconState => validators, balances, block_roots, state_roots, historical_roots, eth1_data_votes, randao_mixes, slashings, inactivity_scores +); +tree_fields!( + bellatrix::BeaconState => validators, balances, block_roots, state_roots, historical_roots, eth1_data_votes, randao_mixes, slashings, inactivity_scores +); +tree_fields!( + capella::BeaconState => validators, balances, block_roots, state_roots, historical_roots, eth1_data_votes, randao_mixes, slashings, inactivity_scores, + historical_summaries +); +tree_fields!( + deneb::BeaconState => validators, balances, block_roots, state_roots, historical_roots, eth1_data_votes, randao_mixes, slashings, inactivity_scores, + historical_summaries +); +tree_fields!( + electra::BeaconState => validators, balances, block_roots, state_roots, historical_roots, eth1_data_votes, randao_mixes, slashings, inactivity_scores, + historical_summaries +); +tree_fields!( + fulu::BeaconState => validators, balances, block_roots, state_roots, historical_roots, eth1_data_votes, randao_mixes, slashings, inactivity_scores, + historical_summaries +); + /// Generates read and write accessors for state fields that every fork shares. /// /// The `copy` and `reference` lists are this crate's statement of which state @@ -537,9 +588,9 @@ impl BeaconState { .ok_or(Error::UnknownValidator(index)) } - /// Folds every buffered write into the tree-backed fields (`validators`, - /// `balances`), so the next `hash_tree_root` rehashes only the touched - /// paths and keeps the hashes it computes. + /// Folds every buffered write into the tree-backed fields (see + /// `tree_fields!` for which ones), so the next `hash_tree_root` rehashes + /// only the touched paths and keeps the hashes it computes. /// /// Hashing with writes still pending gives the right root but caches /// nothing for those paths, so the state transition calls this before @@ -549,11 +600,13 @@ impl BeaconState { if matches!(self, BeaconState::Lean(_)) { return; } - self.validators_mut().apply_updates(); - self.balances_mut().apply_updates(); + dispatch_state!(self, "apply_pending_mutations", |state| state + .buffered_mut() + .into_iter() + .for_each(|field| field.apply_updates())) } - /// Whether `validators` or `balances` has a write [`apply_pending_mutations`] + /// Whether any tree-backed field has a write [`apply_pending_mutations`] /// has not folded into its tree yet. /// /// Always `false` on a lean state, which has no tree-backed fields. Meant @@ -566,7 +619,10 @@ impl BeaconState { if matches!(self, BeaconState::Lean(_)) { return false; } - self.validators().has_pending_updates() || self.balances().has_pending_updates() + dispatch_state!(self, "has_pending_mutations", |state| state + .buffered() + .into_iter() + .any(|field| field.has_pending_updates())) } /// Makes this state's tree-backed fields share every unchanged subtree @@ -574,14 +630,28 @@ impl BeaconState { /// of the registry. The state's contents do not change, only which /// allocations back them. /// - /// Works across forks, since `validators` and `balances` have one type in - /// every fork. A no-op if either state is lean. + /// Works across forks, since the shared fields have one type in every + /// fork. A no-op if either state is lean. pub fn rebase_on(&mut self, base: &BeaconState) { if matches!(self, BeaconState::Lean(_)) || matches!(base, BeaconState::Lean(_)) { return; } self.validators_mut().rebase_on(base.validators()); self.balances_mut().rebase_on(base.balances()); + self.block_roots_mut().rebase_on(base.block_roots()); + self.state_roots_mut().rebase_on(base.state_roots()); + self.historical_roots_mut() + .rebase_on(base.historical_roots()); + self.eth1_data_votes_mut().rebase_on(base.eth1_data_votes()); + self.randao_mixes_mut().rebase_on(base.randao_mixes()); + self.slashings_mut().rebase_on(base.slashings()); + // Phase0 has no scores; the two states may also differ in fork. + if let (Ok((_, _, scores)), Ok((_, _, base_scores))) = ( + self.altair_validator_lists_mut(), + base.altair_validator_lists(), + ) { + scores.rebase_on(base_scores); + } } /// The balance of the validator at `index`. diff --git a/crates/common/types/src/beacon/containers/shared.rs b/crates/common/types/src/beacon/containers/shared.rs index 610a98ff..fa81b73c 100644 --- a/crates/common/types/src/beacon/containers/shared.rs +++ b/crates/common/types/src/beacon/containers/shared.rs @@ -9,7 +9,7 @@ use std::collections::BTreeMap; -use ethlambda_ssz_tree::List; +use ethlambda_ssz_tree::{List, Vector}; use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; use libssz_types::{SszBitvector, SszList, SszVector}; @@ -29,19 +29,29 @@ use crate::beacon::primitives::{ /// The rolling window of recent block roots the state keeps, indexed by slot /// modulo its length so it acts as a ring buffer. -pub type BlockRoots = SszVector; +/// +/// Tree-backed, like every large field: a slot writes one entry, so a derived +/// state shares nearly all of the buffer with its parent and rehashes only the +/// leaf it touched. Writes are buffered in a `BTreeMap`, since they are a +/// handful per flush, until `BeaconState::apply_pending_mutations`. +pub type BlockRoots = Vector>; /// The rolling window of recent state roots, indexed the same way as /// [`BlockRoots`]. -pub type StateRoots = SszVector; +pub type StateRoots = Vector>; /// Accumulated roots of [`HistoricalBatch`], one appended per historical batch, /// which is how the chain keeps a commitment to history older than the rolling /// windows without keeping the roots themselves. -pub type HistoricalRoots = SszList; +/// +/// Tree-backed like [`BlockRoots`]. +pub type HistoricalRoots = List>; /// Eth1 data votes accumulated over one voting period, tallied and then reset. -pub type Eth1DataVotes = SszList; +/// +/// Tree-backed like [`BlockRoots`]. +pub type Eth1DataVotes = + List>; /// The validator registry. Append-only: a validator is never removed, only /// exited, since indices are referenced by attestations and must stay stable. @@ -66,13 +76,18 @@ pub type Balances = List; /// Past randao mixes, indexed by epoch modulo the vector length, so the state /// retains a bounded history of the beacon chain's randomness. -pub type RandaoMixes = SszVector; +/// +/// Tree-backed like [`BlockRoots`]. +pub type RandaoMixes = + Vector>; /// Slashed balance totals per epoch, indexed by epoch modulo the vector length. /// Epoch processing reads the whole vector to size the proportional slashing /// penalty, which is what makes correlated slashings cost more than isolated /// ones. -pub type Slashings = SszVector; +/// +/// Tree-backed like [`BlockRoots`]. +pub type Slashings = Vector>; /// One bit per recent epoch recording whether it was justified, which is the /// state that lets finalization look back over several epochs at once. @@ -91,11 +106,19 @@ pub type EpochParticipation = SszList; +/// +/// Tree-backed: outside a leak almost every score is zero and epoch processing +/// skips writes that change nothing, so a state derived from another shares +/// nearly the whole list with it and rehashes only the touched leaves. Writes +/// are buffered in a `BTreeMap`, since they are sparse in that case. +pub type InactivityScores = List>; /// Accumulated [`HistoricalSummary`] entries, which replace [`HistoricalRoots`] /// as the commitment to history from capella onward. -pub type HistoricalSummaries = SszList; +/// +/// Tree-backed like [`BlockRoots`]. +pub type HistoricalSummaries = + List>; // --------------------------------------------------------------------------- // Misc diff --git a/crates/common/types/src/primitives.rs b/crates/common/types/src/primitives.rs index 0e8cf99f..4e0b3e9f 100644 --- a/crates/common/types/src/primitives.rs +++ b/crates/common/types/src/primitives.rs @@ -31,11 +31,24 @@ pub type ByteList = libssz_types::SszList; Hash, libssz_derive::SszEncode, libssz_derive::SszDecode, - libssz_derive::HashTreeRoot, )] #[ssz(transparent)] pub struct H256(pub [u8; 32]); +/// Written out rather than derived because the `transparent` derive does not +/// forward `is_basic_type`. Without it a collection of `H256` is treated as +/// composite: the same root, but a tree-backed list would cache a second copy +/// of every element's root (see `ethlambda_ssz_tree`). +impl libssz_merkle::HashTreeRoot for H256 { + fn hash_tree_root(&self, hasher: &impl libssz_merkle::Sha256Hasher) -> libssz_merkle::Node { + libssz_merkle::HashTreeRoot::hash_tree_root(&self.0, hasher) + } + + fn is_basic_type() -> bool { + <[u8; 32] as libssz_merkle::HashTreeRoot>::is_basic_type() + } +} + impl serde::Serialize for H256 { fn serialize(&self, serializer: S) -> Result { serializer.serialize_str(&format!("{self}")) @@ -183,4 +196,99 @@ mod tests { fn h256_from_slice_too_long() { H256::from_slice(&[0u8; 33]); } + + /// Reporting `H256` as basic must not change any root: a collection of it + /// merkleizes like one of the plain arrays it wraps. + mod basic_type { + use super::*; + use libssz_types::{SszList, SszVector}; + + #[derive(libssz_derive::HashTreeRoot)] + struct WithH256 { + list: SszList, + vector: SszVector, + tail: u64, + } + + #[derive(libssz_derive::HashTreeRoot)] + struct WithArrays { + list: SszList<[u8; 32], 64>, + vector: SszVector<[u8; 32], 8>, + tail: u64, + } + + fn items(n: usize) -> Vec<[u8; 32]> { + (0..n).map(|i| [i as u8 ^ 0x5a; 32]).collect() + } + + #[test] + fn h256_reports_basic_like_its_array() { + assert!(::is_basic_type()); + } + + #[test] + fn list_and_vector_of_h256_hash_like_arrays() { + for n in [0, 1, 2, 3, 33, 64] { + let arrays = items(n); + let hashes: Vec = arrays.iter().copied().map(H256).collect(); + let a = SszList::<[u8; 32], 64>::try_from(arrays).unwrap(); + let h = SszList::::try_from(hashes).unwrap(); + assert_eq!( + HashTreeRoot::hash_tree_root(&a), + HashTreeRoot::hash_tree_root(&h), + "list of {n}" + ); + } + let arrays = items(8); + let hashes: Vec = arrays.iter().copied().map(H256).collect(); + let a = SszVector::<[u8; 32], 8>::try_from(arrays).unwrap(); + let h = SszVector::::try_from(hashes).unwrap(); + assert_eq!( + HashTreeRoot::hash_tree_root(&a), + HashTreeRoot::hash_tree_root(&h) + ); + } + + #[test] + fn a_container_holding_h256_collections_keeps_its_root() { + let arrays = items(8); + let hashes: Vec = arrays.iter().copied().map(H256).collect(); + let with_arrays = WithArrays { + list: arrays.clone().try_into().unwrap(), + vector: arrays.try_into().unwrap(), + tail: 7, + }; + let with_h256 = WithH256 { + list: hashes.clone().try_into().unwrap(), + vector: hashes.try_into().unwrap(), + tail: 7, + }; + assert_eq!( + HashTreeRoot::hash_tree_root(&with_arrays), + HashTreeRoot::hash_tree_root(&with_h256) + ); + } + + /// The lean state's `H256` lists go through the same path. + #[test] + fn lean_state_root_is_unchanged_by_the_basic_report() { + use crate::state::State; + let mut state = State::from_genesis(0, Vec::new()); + let hashes: Vec = items(5).into_iter().map(H256).collect(); + state.historical_block_hashes = hashes.try_into().unwrap(); + let mirror = SszList::<[u8; 32], 262_144>::try_from(items(5)).unwrap(); + let expected = + libssz_merkle::HashTreeRoot::hash_tree_root(&mirror, &libssz_merkle::Sha2Hasher); + let got = libssz_merkle::HashTreeRoot::hash_tree_root( + &state.historical_block_hashes, + &libssz_merkle::Sha2Hasher, + ); + assert_eq!(got, expected); + // The container root is computed and stable across calls. + assert_eq!( + HashTreeRoot::hash_tree_root(&state), + HashTreeRoot::hash_tree_root(&state.clone()) + ); + } + } } diff --git a/crates/common/types/tests/beacon_json.rs b/crates/common/types/tests/beacon_json.rs index 7809cf5a..5f50bbd6 100644 --- a/crates/common/types/tests/beacon_json.rs +++ b/crates/common/types/tests/beacon_json.rs @@ -105,6 +105,16 @@ mod fixtures { SszVector::try_from(vec![value; N]).expect("exactly N elements by construction") } + /// [`vector`] for the state's tree-backed ring buffers. + pub fn tree_vector(value: T) -> ethlambda_ssz_tree::Vector + where + T: ethlambda_ssz_tree::Value, + U: ethlambda_ssz_tree::UpdateMap, + { + ethlambda_ssz_tree::Vector::try_from(vec![value; N]) + .expect("exactly N elements by construction") + } + pub fn checkpoint(seed: u8) -> shared::Checkpoint { shared::Checkpoint { epoch: u64::from(seed) + 1, @@ -349,18 +359,19 @@ fn phase0_state() -> phase0::BeaconState { epoch: 1, }, latest_block_header: fixtures::beacon_block_header(7), - block_roots: fixtures::vector(H256::repeat_byte(0x77)), - state_roots: fixtures::vector(H256::repeat_byte(0x88)), - historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + block_roots: fixtures::tree_vector(H256::repeat_byte(0x77)), + state_roots: fixtures::tree_vector(H256::repeat_byte(0x88)), + historical_roots: ethlambda_ssz_tree::List::try_from(vec![H256::repeat_byte(0x99)]) .expect("within capacity"), eth1_data: fixtures::eth1_data(8), - eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_data_votes: ethlambda_ssz_tree::List::try_from(vec![fixtures::eth1_data(9)]) + .expect("within capacity"), eth1_deposit_index: 3, validators: shared::Validators::try_from(vec![fixtures::validator(10)]) .expect("within capacity"), balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), - randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), - slashings: fixtures::vector(1_000_000_000u64), + randao_mixes: fixtures::tree_vector(H256::repeat_byte(0xaa)), + slashings: fixtures::tree_vector(1_000_000_000u64), previous_epoch_attestations: SszList::try_from(vec![pending_attestation(11)]) .expect("within capacity"), current_epoch_attestations: SszList::try_from(vec![pending_attestation(12)]) @@ -468,18 +479,19 @@ fn altair_state() -> altair::BeaconState { epoch: 1, }, latest_block_header: fixtures::beacon_block_header(7), - block_roots: fixtures::vector(H256::repeat_byte(0x77)), - state_roots: fixtures::vector(H256::repeat_byte(0x88)), - historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + block_roots: fixtures::tree_vector(H256::repeat_byte(0x77)), + state_roots: fixtures::tree_vector(H256::repeat_byte(0x88)), + historical_roots: ethlambda_ssz_tree::List::try_from(vec![H256::repeat_byte(0x99)]) .expect("within capacity"), eth1_data: fixtures::eth1_data(8), - eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_data_votes: ethlambda_ssz_tree::List::try_from(vec![fixtures::eth1_data(9)]) + .expect("within capacity"), eth1_deposit_index: 3, validators: shared::Validators::try_from(vec![fixtures::validator(10)]) .expect("within capacity"), balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), - randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), - slashings: fixtures::vector(1_000_000_000u64), + randao_mixes: fixtures::tree_vector(H256::repeat_byte(0xaa)), + slashings: fixtures::tree_vector(1_000_000_000u64), previous_epoch_participation: SszList::try_from(vec![7u8]).expect("within capacity"), current_epoch_participation: SszList::try_from(vec![9u8]).expect("within capacity"), justification_bits: { @@ -490,7 +502,7 @@ fn altair_state() -> altair::BeaconState { previous_justified_checkpoint: fixtures::checkpoint(13), current_justified_checkpoint: fixtures::checkpoint(14), finalized_checkpoint: fixtures::checkpoint(15), - inactivity_scores: SszList::try_from(vec![4u64]).expect("within capacity"), + inactivity_scores: vec![4u64].try_into().expect("within capacity"), current_sync_committee: sync_committee(16), next_sync_committee: sync_committee(17), } @@ -614,18 +626,19 @@ fn bellatrix_state() -> bellatrix::BeaconState { epoch: 1, }, latest_block_header: fixtures::beacon_block_header(7), - block_roots: fixtures::vector(H256::repeat_byte(0x77)), - state_roots: fixtures::vector(H256::repeat_byte(0x88)), - historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + block_roots: fixtures::tree_vector(H256::repeat_byte(0x77)), + state_roots: fixtures::tree_vector(H256::repeat_byte(0x88)), + historical_roots: ethlambda_ssz_tree::List::try_from(vec![H256::repeat_byte(0x99)]) .expect("within capacity"), eth1_data: fixtures::eth1_data(8), - eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_data_votes: ethlambda_ssz_tree::List::try_from(vec![fixtures::eth1_data(9)]) + .expect("within capacity"), eth1_deposit_index: 3, validators: shared::Validators::try_from(vec![fixtures::validator(10)]) .expect("within capacity"), balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), - randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), - slashings: fixtures::vector(1_000_000_000u64), + randao_mixes: fixtures::tree_vector(H256::repeat_byte(0xaa)), + slashings: fixtures::tree_vector(1_000_000_000u64), previous_epoch_participation: SszList::try_from(vec![7u8]).expect("within capacity"), current_epoch_participation: SszList::try_from(vec![9u8]).expect("within capacity"), justification_bits: { @@ -636,7 +649,7 @@ fn bellatrix_state() -> bellatrix::BeaconState { previous_justified_checkpoint: fixtures::checkpoint(13), current_justified_checkpoint: fixtures::checkpoint(14), finalized_checkpoint: fixtures::checkpoint(15), - inactivity_scores: SszList::try_from(vec![4u64]).expect("within capacity"), + inactivity_scores: vec![4u64].try_into().expect("within capacity"), current_sync_committee: sync_committee(16), next_sync_committee: sync_committee(17), latest_execution_payload_header: execution_payload_header(30), @@ -770,18 +783,19 @@ fn capella_state() -> capella::BeaconState { epoch: 1, }, latest_block_header: fixtures::beacon_block_header(7), - block_roots: fixtures::vector(H256::repeat_byte(0x77)), - state_roots: fixtures::vector(H256::repeat_byte(0x88)), - historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + block_roots: fixtures::tree_vector(H256::repeat_byte(0x77)), + state_roots: fixtures::tree_vector(H256::repeat_byte(0x88)), + historical_roots: ethlambda_ssz_tree::List::try_from(vec![H256::repeat_byte(0x99)]) .expect("within capacity"), eth1_data: fixtures::eth1_data(8), - eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_data_votes: ethlambda_ssz_tree::List::try_from(vec![fixtures::eth1_data(9)]) + .expect("within capacity"), eth1_deposit_index: 3, validators: shared::Validators::try_from(vec![fixtures::validator(10)]) .expect("within capacity"), balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), - randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), - slashings: fixtures::vector(1_000_000_000u64), + randao_mixes: fixtures::tree_vector(H256::repeat_byte(0xaa)), + slashings: fixtures::tree_vector(1_000_000_000u64), previous_epoch_participation: SszList::try_from(vec![7u8]).expect("within capacity"), current_epoch_participation: SszList::try_from(vec![9u8]).expect("within capacity"), justification_bits: { @@ -792,13 +806,13 @@ fn capella_state() -> capella::BeaconState { previous_justified_checkpoint: fixtures::checkpoint(13), current_justified_checkpoint: fixtures::checkpoint(14), finalized_checkpoint: fixtures::checkpoint(15), - inactivity_scores: SszList::try_from(vec![4u64]).expect("within capacity"), + inactivity_scores: vec![4u64].try_into().expect("within capacity"), current_sync_committee: sync_committee(16), next_sync_committee: sync_committee(17), latest_execution_payload_header: capella_execution_payload_header(30), next_withdrawal_index: 40, next_withdrawal_validator_index: 41, - historical_summaries: SszList::try_from(vec![shared::HistoricalSummary { + historical_summaries: ethlambda_ssz_tree::List::try_from(vec![shared::HistoricalSummary { block_summary_root: H256::repeat_byte(0xbb), state_summary_root: H256::repeat_byte(0xcc), }]) @@ -938,18 +952,19 @@ fn deneb_state() -> deneb::BeaconState { epoch: 1, }, latest_block_header: fixtures::beacon_block_header(7), - block_roots: fixtures::vector(H256::repeat_byte(0x77)), - state_roots: fixtures::vector(H256::repeat_byte(0x88)), - historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + block_roots: fixtures::tree_vector(H256::repeat_byte(0x77)), + state_roots: fixtures::tree_vector(H256::repeat_byte(0x88)), + historical_roots: ethlambda_ssz_tree::List::try_from(vec![H256::repeat_byte(0x99)]) .expect("within capacity"), eth1_data: fixtures::eth1_data(8), - eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_data_votes: ethlambda_ssz_tree::List::try_from(vec![fixtures::eth1_data(9)]) + .expect("within capacity"), eth1_deposit_index: 3, validators: shared::Validators::try_from(vec![fixtures::validator(10)]) .expect("within capacity"), balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), - randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), - slashings: fixtures::vector(1_000_000_000u64), + randao_mixes: fixtures::tree_vector(H256::repeat_byte(0xaa)), + slashings: fixtures::tree_vector(1_000_000_000u64), previous_epoch_participation: SszList::try_from(vec![7u8]).expect("within capacity"), current_epoch_participation: SszList::try_from(vec![9u8]).expect("within capacity"), justification_bits: { @@ -960,13 +975,13 @@ fn deneb_state() -> deneb::BeaconState { previous_justified_checkpoint: fixtures::checkpoint(13), current_justified_checkpoint: fixtures::checkpoint(14), finalized_checkpoint: fixtures::checkpoint(15), - inactivity_scores: SszList::try_from(vec![4u64]).expect("within capacity"), + inactivity_scores: vec![4u64].try_into().expect("within capacity"), current_sync_committee: sync_committee(16), next_sync_committee: sync_committee(17), latest_execution_payload_header: deneb_execution_payload_header(30), next_withdrawal_index: 40, next_withdrawal_validator_index: 41, - historical_summaries: SszList::try_from(vec![shared::HistoricalSummary { + historical_summaries: ethlambda_ssz_tree::List::try_from(vec![shared::HistoricalSummary { block_summary_root: H256::repeat_byte(0xbb), state_summary_root: H256::repeat_byte(0xcc), }]) @@ -1132,18 +1147,19 @@ fn electra_state() -> electra::BeaconState { epoch: 1, }, latest_block_header: fixtures::beacon_block_header(7), - block_roots: fixtures::vector(H256::repeat_byte(0x77)), - state_roots: fixtures::vector(H256::repeat_byte(0x88)), - historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + block_roots: fixtures::tree_vector(H256::repeat_byte(0x77)), + state_roots: fixtures::tree_vector(H256::repeat_byte(0x88)), + historical_roots: ethlambda_ssz_tree::List::try_from(vec![H256::repeat_byte(0x99)]) .expect("within capacity"), eth1_data: fixtures::eth1_data(8), - eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_data_votes: ethlambda_ssz_tree::List::try_from(vec![fixtures::eth1_data(9)]) + .expect("within capacity"), eth1_deposit_index: 3, validators: shared::Validators::try_from(vec![fixtures::validator(10)]) .expect("within capacity"), balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), - randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), - slashings: fixtures::vector(1_000_000_000u64), + randao_mixes: fixtures::tree_vector(H256::repeat_byte(0xaa)), + slashings: fixtures::tree_vector(1_000_000_000u64), previous_epoch_participation: SszList::try_from(vec![7u8]).expect("within capacity"), current_epoch_participation: SszList::try_from(vec![9u8]).expect("within capacity"), justification_bits: { @@ -1154,13 +1170,13 @@ fn electra_state() -> electra::BeaconState { previous_justified_checkpoint: fixtures::checkpoint(13), current_justified_checkpoint: fixtures::checkpoint(14), finalized_checkpoint: fixtures::checkpoint(15), - inactivity_scores: SszList::try_from(vec![4u64]).expect("within capacity"), + inactivity_scores: vec![4u64].try_into().expect("within capacity"), current_sync_committee: sync_committee(16), next_sync_committee: sync_committee(17), latest_execution_payload_header: deneb_execution_payload_header(30), next_withdrawal_index: 40, next_withdrawal_validator_index: 41, - historical_summaries: SszList::try_from(vec![shared::HistoricalSummary { + historical_summaries: ethlambda_ssz_tree::List::try_from(vec![shared::HistoricalSummary { block_summary_root: H256::repeat_byte(0xbb), state_summary_root: H256::repeat_byte(0xcc), }]) @@ -1222,18 +1238,19 @@ fn fulu_state() -> fulu::BeaconState { epoch: 1, }, latest_block_header: fixtures::beacon_block_header(7), - block_roots: fixtures::vector(H256::repeat_byte(0x77)), - state_roots: fixtures::vector(H256::repeat_byte(0x88)), - historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + block_roots: fixtures::tree_vector(H256::repeat_byte(0x77)), + state_roots: fixtures::tree_vector(H256::repeat_byte(0x88)), + historical_roots: ethlambda_ssz_tree::List::try_from(vec![H256::repeat_byte(0x99)]) .expect("within capacity"), eth1_data: fixtures::eth1_data(8), - eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_data_votes: ethlambda_ssz_tree::List::try_from(vec![fixtures::eth1_data(9)]) + .expect("within capacity"), eth1_deposit_index: 3, validators: shared::Validators::try_from(vec![fixtures::validator(10)]) .expect("within capacity"), balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), - randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), - slashings: fixtures::vector(1_000_000_000u64), + randao_mixes: fixtures::tree_vector(H256::repeat_byte(0xaa)), + slashings: fixtures::tree_vector(1_000_000_000u64), previous_epoch_participation: SszList::try_from(vec![7u8]).expect("within capacity"), current_epoch_participation: SszList::try_from(vec![9u8]).expect("within capacity"), justification_bits: { @@ -1244,13 +1261,13 @@ fn fulu_state() -> fulu::BeaconState { previous_justified_checkpoint: fixtures::checkpoint(13), current_justified_checkpoint: fixtures::checkpoint(14), finalized_checkpoint: fixtures::checkpoint(15), - inactivity_scores: SszList::try_from(vec![4u64]).expect("within capacity"), + inactivity_scores: vec![4u64].try_into().expect("within capacity"), current_sync_committee: sync_committee(16), next_sync_committee: sync_committee(17), latest_execution_payload_header: deneb_execution_payload_header(30), next_withdrawal_index: 40, next_withdrawal_validator_index: 41, - historical_summaries: SszList::try_from(vec![shared::HistoricalSummary { + historical_summaries: ethlambda_ssz_tree::List::try_from(vec![shared::HistoricalSummary { block_summary_root: H256::repeat_byte(0xbb), state_summary_root: H256::repeat_byte(0xcc), }]) diff --git a/crates/net/rpc/src/beacon/validator.rs b/crates/net/rpc/src/beacon/validator.rs index abbbd95e..d080901c 100644 --- a/crates/net/rpc/src/beacon/validator.rs +++ b/crates/net/rpc/src/beacon/validator.rs @@ -452,8 +452,8 @@ mod tests { unreachable!("built as fulu") }; fulu.proposer_lookahead = lookahead.try_into().unwrap(); - for (slot, root) in fulu.block_roots.iter_mut().enumerate() { - *root = H256::repeat_byte(slot as u8 + 1); + for slot in 0..fulu.block_roots.len() { + fulu.block_roots[slot] = H256::repeat_byte(slot as u8 + 1); } state } diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index a2144f7c..df473697 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -251,7 +251,6 @@ pub(crate) mod test_utils { state::{JustificationValidators, JustifiedSlots, State, StateConfig}, }; use libssz::SszEncode; - use libssz_types::SszVector; /// Build the API router the way tests do, with placeholder client version /// and peer ID. Tests that assert on those identity values (e.g. the @@ -360,9 +359,11 @@ pub(crate) mod test_utils { state_root: H256::ZERO, body_root: H256::ZERO, }, - block_roots: SszVector::try_from(vec![H256::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT]) + block_roots: vec![H256::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT] + .try_into() .expect("exactly N elements by construction"), - state_roots: SszVector::try_from(vec![H256::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT]) + state_roots: vec![H256::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT] + .try_into() .expect("exactly N elements by construction"), historical_roots: Default::default(), eth1_data: Default::default(), @@ -370,12 +371,11 @@ pub(crate) mod test_utils { eth1_deposit_index: 0, validators: Default::default(), balances: Default::default(), - randao_mixes: SszVector::try_from(vec![ - H256::ZERO; - preset::EPOCHS_PER_HISTORICAL_VECTOR - ]) - .expect("exactly N elements by construction"), - slashings: SszVector::try_from(vec![0u64; preset::EPOCHS_PER_SLASHINGS_VECTOR]) + randao_mixes: vec![H256::ZERO; preset::EPOCHS_PER_HISTORICAL_VECTOR] + .try_into() + .expect("exactly N elements by construction"), + slashings: vec![0u64; preset::EPOCHS_PER_SLASHINGS_VECTOR] + .try_into() .expect("exactly N elements by construction"), previous_epoch_attestations: Default::default(), current_epoch_attestations: Default::default(), diff --git a/crates/storage/src/beacon_state_delta.rs b/crates/storage/src/beacon_state_delta.rs index 11e7ec37..44233b42 100644 --- a/crates/storage/src/beacon_state_delta.rs +++ b/crates/storage/src/beacon_state_delta.rs @@ -113,7 +113,7 @@ pub(crate) fn unframe(bytes: &[u8]) -> (H256, u64, u64, &[u8]) { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use proptest::prelude::*; @@ -289,7 +289,14 @@ mod tests { /// [`advance_across_epoch_boundary`]) are counted too, which is why every /// test that calls this is `#[ignore]`d. fn mainnet_scale_electra_state() -> electra::BeaconState { - let validators: Vec = (0..VALIDATOR_COUNT) + electra_state_with_validators(VALIDATOR_COUNT) + } + + /// [`mainnet_scale_electra_state`] with `count` validators, for tests that + /// need the electra shape (every tree-backed field populated) without the + /// mainnet cost. + pub(crate) fn electra_state_with_validators(count: usize) -> electra::BeaconState { + let validators: Vec = (0..count) .map(|_| Validator { effective_balance: preset::MIN_ACTIVATION_BALANCE, activation_eligibility_epoch: 0, @@ -320,33 +327,33 @@ mod tests { eth1_deposit_index: 0, validators: validators .try_into() - .expect("VALIDATOR_COUNT is far below VALIDATOR_REGISTRY_LIMIT"), - balances: vec![preset::MIN_ACTIVATION_BALANCE; VALIDATOR_COUNT] + .expect("`count` is far below VALIDATOR_REGISTRY_LIMIT"), + balances: vec![preset::MIN_ACTIVATION_BALANCE; count] .try_into() - .expect("VALIDATOR_COUNT is far below VALIDATOR_REGISTRY_LIMIT"), + .expect("`count` is far below VALIDATOR_REGISTRY_LIMIT"), randao_mixes: vec![H256::ZERO; preset::EPOCHS_PER_HISTORICAL_VECTOR] .try_into() .expect("the vector is built at its exact length"), slashings: vec![0; preset::EPOCHS_PER_SLASHINGS_VECTOR] .try_into() .expect("the vector is built at its exact length"), - previous_epoch_participation: vec![0u8; VALIDATOR_COUNT] + previous_epoch_participation: vec![0u8; count] .try_into() - .expect("VALIDATOR_COUNT is far below VALIDATOR_REGISTRY_LIMIT"), + .expect("`count` is far below VALIDATOR_REGISTRY_LIMIT"), // Uniform and non-zero, standing in for "everyone was timely last // epoch": what makes advance_across_epoch_boundary's shift of this // into `previous_epoch_participation` a real change rather than a // zero-to-zero no-op. - current_epoch_participation: vec![0b0000_0111u8; VALIDATOR_COUNT] + current_epoch_participation: vec![0b0000_0111u8; count] .try_into() - .expect("VALIDATOR_COUNT is far below VALIDATOR_REGISTRY_LIMIT"), + .expect("`count` is far below VALIDATOR_REGISTRY_LIMIT"), justification_bits: Default::default(), previous_justified_checkpoint: Default::default(), current_justified_checkpoint: Default::default(), finalized_checkpoint: Default::default(), - inactivity_scores: vec![0u64; VALIDATOR_COUNT] + inactivity_scores: vec![0u64; count] .try_into() - .expect("VALIDATOR_COUNT is far below VALIDATOR_REGISTRY_LIMIT"), + .expect("`count` is far below VALIDATOR_REGISTRY_LIMIT"), current_sync_committee: empty_sync_committee(), next_sync_committee: empty_sync_committee(), latest_execution_payload_header: execution_payload_header_for_slot(0), @@ -419,8 +426,8 @@ mod tests { for index in 0..state.balances.len() { state.balances[index] = balance_for_index(index); } - for (index, score) in state.inactivity_scores.iter_mut().enumerate() { - *score = inactivity_score_for_index(index); + for index in 0..state.inactivity_scores.len() { + state.inactivity_scores[index] = inactivity_score_for_index(index); } // Most validators' effective balance moves; hysteresis means a diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 96addfbd..f05d8c0e 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -5621,6 +5621,60 @@ mod tests { assert_eq!(decoded.to_ssz(), child.to_ssz()); } + /// The tree-backed fields beyond the registry share with the resident + /// parent too, across a state whose scores and previous participation did + /// not change but whose balances did. + #[test] + fn a_decoded_electra_state_shares_its_tree_fields_with_the_resident_parent() { + use crate::beacon_state_delta::tests::electra_state_with_validators; + + let backend: Arc = Arc::new(InMemoryBackend::new()); + let mut store = beacon_test_store(backend.clone()); + let parent_root = H256::from([1u8; 32]); + let child_root = H256::from([2u8; 32]); + + let mut parent = BeaconState::Electra(electra_state_with_validators(600)); + parent.apply_pending_mutations(); + store + .insert_signed_block(parent_root, beacon_test_block(10, H256::ZERO)) + .expect("insert parent block"); + store + .insert_state(parent_root, parent.clone()) + .expect("insert parent state"); + + let mut child = parent; + *child.slot_mut() += 1; + child.latest_block_header_mut().parent_root = parent_root; + child.balances_mut()[0] += 1; + child.block_roots_mut()[10] = parent_root; + child.apply_pending_mutations(); + store + .insert_signed_block(child_root, beacon_test_block(11, parent_root)) + .expect("insert child block"); + store + .insert_state(child_root, child.clone()) + .expect("insert child state"); + + drop(store); + let cold = beacon_test_store(backend); + let resident = cold.get_state(&parent_root).expect("get").expect("present"); + let decoded = cold.get_state(&child_root).expect("get").expect("present"); + + assert!(decoded.validators().ptr_eq(resident.validators())); + let (_, _, decoded_scores) = decoded.altair_validator_lists().unwrap(); + let (_, _, resident_scores) = resident.altair_validator_lists().unwrap(); + assert!(decoded_scores.ptr_eq(resident_scores)); + assert!(decoded.randao_mixes().ptr_eq(resident.randao_mixes())); + assert!(decoded.slashings().ptr_eq(resident.slashings())); + assert!( + decoded + .historical_roots() + .ptr_eq(resident.historical_roots()) + ); + assert!(decoded.eth1_data_votes().ptr_eq(resident.eth1_data_votes())); + assert_eq!(decoded.to_ssz(), child.to_ssz()); + } + /// `beacon_test_state` with its parent linked in, the way `insert_state`'s /// beacon arm expects: it reads the base to diff against off the /// post-state's own `latest_block_header.parent_root`, mirroring how the