diff --git a/crates/blockchain/state_transition/src/beacon/block_production.rs b/crates/blockchain/state_transition/src/beacon/block_production.rs index ac47f526e..9c3d0f8ce 100644 --- a/crates/blockchain/state_transition/src/beacon/block_production.rs +++ b/crates/blockchain/state_transition/src/beacon/block_production.rs @@ -594,6 +594,10 @@ pub fn assemble_block( &ActiveBalanceCache::default(), options, )?; + // 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 7ac49fee4..f33f7641a 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs @@ -104,7 +104,7 @@ pub(super) fn weigh_with_totals( /// epoch's participation and genesis has none. /// /// Shared by every fork from altair on, gloas included: reading -/// [`crate::beacon::containers::BeaconState::inactivity_scores_mut`] rather than +/// [`crate::beacon::containers::BeaconState::inactivity_score_mut`] rather than /// [`crate::beacon::containers::BeaconState::altair_validator_lists_mut`] is what /// makes that so, since this only ever writes a score in place and never /// grows or replaces the list, and that narrower accessor extends to a gloas @@ -129,26 +129,40 @@ fn update_inactivity_scores( ) -> Result<()> { let leaking = is_in_inactivity_leak(state)?; - let inactivity_scores = state.inactivity_scores_mut()?; - let score_count = inactivity_scores.len(); - for (index, (flags, _)) in summary.iter().enumerate() { - if !flags.is_eligible() { - continue; - } - let score = inactivity_scores - .get_mut(index) - .ok_or(Error::IndexOutOfBounds { + // Decided over an in-order walk of the scores and written afterwards: a + // tree-backed list is read with an iterator (one descent per element would + // dominate), and only a score that changes is written, since outside a + // leak almost every score stays zero and a write of an equal value would + // still unshare its leaf from the parent state's. + let changes = { + let (_, _, inactivity_scores) = state.altair_validator_lists()?; + let score_count = inactivity_scores.len(); + let mut score_iter = inactivity_scores.iter(); + let mut changes: Vec<(usize, u64)> = Vec::new(); + for (index, (flags, _)) in summary.iter().enumerate() { + // Advanced for ineligible validators too, to stay in step with `index`; + // a score list shorter than the registry is an error only at the first + // eligible validator without one. + let score = score_iter.next().copied(); + if !flags.is_eligible() { + continue; + } + let score = score.ok_or(Error::IndexOutOfBounds { index, len: score_count, })?; - let updated = next_inactivity_score(*score, flags, leaking, config)?; - - if updated != *score { - *score = updated; + let updated = next_inactivity_score(score, flags, leaking, config)?; + if updated != score { + changes.push((index, updated)); + } } - } + changes + }; + for (index, updated) in changes { + *state.inactivity_score_mut(index)? = updated; + } Ok(()) } @@ -515,7 +529,7 @@ mod tests { process_inactivity_updates(&mut state, &Config::mainnet()).unwrap(); let (_, _, scores) = state.altair_validator_lists().unwrap(); - assert_eq!(scores, before); + assert_eq!(scores.to_vec(), before); } #[test] @@ -570,6 +584,58 @@ 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 = state.clone(); + let (_, _, before) = before_state.altair_validator_lists().unwrap(); + + 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/epoch/gloas.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/gloas.rs index abb21585b..c07dddf8b 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/epoch/gloas.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/gloas.rs @@ -31,7 +31,7 @@ //! [`super::altair::process_rewards_and_penalties`]) reach //! `previous_epoch_participation`/`current_epoch_participation`/`inactivity_scores` //! as plain slices, through [`crate::beacon::containers::BeaconState::altair_validator_lists`] -//! and [`crate::beacon::containers::BeaconState::inactivity_scores_mut`]: both +//! and [`crate::beacon::containers::BeaconState::inactivity_score_mut`]: both //! `SszList` and `ProgressiveList` `Deref` to a plain `[T]`. //! - **[`super::electra::process_pending_consolidations`]** takes the queue //! as a `Vec` and writes it back, through diff --git a/crates/blockchain/state_transition/src/beacon/stf/mod.rs b/crates/blockchain/state_transition/src/beacon/stf/mod.rs index d5b9dfbf0..8a57ca638 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/mod.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/mod.rs @@ -357,6 +357,7 @@ mod tests { use super::*; use crate::beacon::fork::ForkName; use crate::beacon::helpers::test_state; + use crate::beacon::primitives::Root; #[test] fn process_slot_unsets_next_slots_payload_availability_on_gloas() { @@ -433,7 +434,11 @@ mod tests { process_slot(&mut state).unwrap(); - assert!(!state.has_pending_mutations()); + assert!(!state.registry_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 @@ -452,4 +457,112 @@ mod tests { 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; 8] = [ + ForkName::Phase0, + ForkName::Altair, + ForkName::Bellatrix, + ForkName::Capella, + ForkName::Deneb, + ForkName::Electra, + ForkName::Fulu, + ForkName::Gloas, + ]; + + /// 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(), + BeaconState::Gloas(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.balance_mut(0).unwrap() += 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 + }), + // Gloas keeps its scores in a flat progressive list that buffers + // nothing, so a write there leaves nothing pending. + ("inactivity_scores", |s| { + let buffered = !matches!(s, BeaconState::Gloas(_)); + match s.inactivity_score_mut(0) { + Ok(score) => { + *score += 1; + buffered + } + 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 dbcaa52b0..311d3637a 100644 --- a/crates/blockchain/state_transition/src/beacon/upgrade.rs +++ b/crates/blockchain/state_transition/src/beacon/upgrade.rs @@ -1411,8 +1411,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/blockchain/state_transition/tests/beacon_spec/epoch_processing.rs b/crates/blockchain/state_transition/tests/beacon_spec/epoch_processing.rs index ebe40f15d..cc92102d9 100644 --- a/crates/blockchain/state_transition/tests/beacon_spec/epoch_processing.rs +++ b/crates/blockchain/state_transition/tests/beacon_spec/epoch_processing.rs @@ -185,15 +185,15 @@ fn apply( // module's own doc. `inactivity_updates` reads gloas too, below: its // participation and inactivity lists are progressive (EIP-7688), a // different Rust type from the bounded `SszList` altair's own copy - // used to need, but `BeaconState::inactivity_scores_mut` and - // `altair_validator_lists` both now reach either list kind through a - // shared slice type. `participation_flag_updates`, just below, is the + // used to need, but `BeaconState::inactivity_score_mut` and + // `altair_validator_lists` both now reach either list kind (the latter + // through `InactivityScoresRef`). `participation_flag_updates`, just below, is the // one exception: it *replaces* the whole list rather than writing an // element, which only the fork's own concrete container type can do, // so gloas keeps its own copy of that one step; see // `epoch::gloas`'s own module doc. "inactivity_updates" => match fork { - // `BeaconState::inactivity_scores_mut` reaches a gloas state's + // `BeaconState::inactivity_score_mut` reaches a gloas state's // scores too (element writes only, which both list kinds allow // through `DerefMut`; see that accessor's own doc), so this one // copy serves gloas as well. diff --git a/crates/common/ssz-tree/src/lib.rs b/crates/common/ssz-tree/src/lib.rs index bb3866152..fbac1f77b 100644 --- a/crates/common/ssz-tree/src/lib.rs +++ b/crates/common/ssz-tree/src/lib.rs @@ -91,6 +91,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 62efc6ab8..a36fbb57a 100644 --- a/crates/common/ssz-tree/src/list.rs +++ b/crates/common/ssz-tree/src/list.rs @@ -137,6 +137,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/progressive_list.rs b/crates/common/ssz-tree/src/progressive_list.rs index 861b5b187..63faaf4a2 100644 --- a/crates/common/ssz-tree/src/progressive_list.rs +++ b/crates/common/ssz-tree/src/progressive_list.rs @@ -230,6 +230,16 @@ impl> ProgressiveIterCow<'_, T, U> { } } +impl> crate::Buffered for ProgressiveList { + fn apply_updates(&mut self) { + ProgressiveList::apply_updates(self); + } + + fn has_pending_updates(&self) -> bool { + ProgressiveList::has_pending_updates(self) + } +} + impl> Default for ProgressiveList { fn default() -> Self { Self::empty() diff --git a/crates/common/ssz-tree/src/vector.rs b/crates/common/ssz-tree/src/vector.rs index 45a1d0238..bea49b4a5 100644 --- a/crates/common/ssz-tree/src/vector.rs +++ b/crates/common/ssz-tree/src/vector.rs @@ -108,6 +108,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 a56db8597..cafc76a6e 100644 --- a/crates/common/ssz-tree/tests/model.rs +++ b/crates/common/ssz-tree/tests/model.rs @@ -1279,3 +1279,221 @@ proptest! { check_list_matches(&list, &items_model)?; } } + +// ── 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 9bcd8947c..a7859de2e 100644 --- a/crates/common/types/src/beacon/containers/mod.rs +++ b/crates/common/types/src/beacon/containers/mod.rs @@ -406,6 +406,64 @@ 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 +); +// Gloas keeps `validators` and `balances` in progressive trees, and its +// `inactivity_scores` in a flat progressive list that buffers nothing, so the +// scores are not listed. Every other field is the shared tree type. +tree_fields!( + gloas::BeaconState => validators, balances, block_roots, state_roots, historical_roots, eth1_data_votes, randao_mixes, slashings, + 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 @@ -555,6 +613,133 @@ enum RegistryMut<'a> { Progressive(&'a mut ProgressiveValidators, &'a mut ProgressiveBalances), } +/// The inactivity scores of a state, whichever list kind its fork uses. +/// +/// Every fork before gloas keeps them in a tree-backed list, which has no +/// slice view; gloas keeps a flat progressive list. A read needs only length, +/// element and in-order access, which this offers over both. +#[derive(Clone, Copy)] +pub enum InactivityScoresRef<'a> { + /// A tree-backed list (altair through fulu). + Tree(&'a InactivityScores), + /// A flat list (gloas). + Flat(&'a [u64]), +} + +impl<'a> From<&'a InactivityScores> for InactivityScoresRef<'a> { + fn from(scores: &'a InactivityScores) -> Self { + Self::Tree(scores) + } +} + +impl<'a> From<&'a gloas::InactivityScores> for InactivityScoresRef<'a> { + fn from(scores: &'a gloas::InactivityScores) -> Self { + Self::Flat(&scores[..]) + } +} + +impl<'a> InactivityScoresRef<'a> { + /// The number of scores. + pub fn len(&self) -> usize { + match self { + Self::Tree(scores) => scores.len(), + Self::Flat(scores) => scores.len(), + } + } + + /// Whether there are no scores. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// The score at `index`, or `None` past the end. A tree descent for a + /// tree-backed list: walk [`Self::iter`] when visiting many. + pub fn get(&self, index: usize) -> Option<&'a u64> { + match self { + Self::Tree(scores) => scores.get(index), + Self::Flat(scores) => scores.get(index), + } + } + + /// Every score in order. + pub fn iter(&self) -> impl ExactSizeIterator + use<'a> { + match self { + Self::Tree(scores) => ScoresIter::Tree(scores.iter()), + Self::Flat(scores) => ScoresIter::Flat(scores.iter()), + } + } + + /// Whether a write is buffered and not yet folded into the tree. Always + /// `false` for a flat list, which buffers nothing. + pub fn has_pending_updates(&self) -> bool { + match self { + Self::Tree(scores) => scores.has_pending_updates(), + Self::Flat(_) => false, + } + } + + /// Whether both are tree-backed lists sharing one allocation. Never true + /// for a flat list, which shares nothing. + pub fn ptr_eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Tree(a), Self::Tree(b)) => a.ptr_eq(b), + _ => false, + } + } + + /// The scores, copied out. + pub fn to_vec(&self) -> Vec { + self.iter().copied().collect() + } +} + +impl PartialEq for InactivityScoresRef<'_> { + fn eq(&self, other: &Self) -> bool { + self.iter().eq(other.iter()) + } +} + +impl std::fmt::Debug for InactivityScoresRef<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_list().entries(self.iter()).finish() + } +} + +impl std::ops::Index for InactivityScoresRef<'_> { + type Output = u64; + + fn index(&self, index: usize) -> &u64 { + self.get(index) + .unwrap_or_else(|| panic!("index {index} out of bounds for {} scores", self.len())) + } +} + +/// The iterator behind [`InactivityScoresRef::iter`]. +enum ScoresIter<'a> { + Tree(ethlambda_ssz_tree::Iter<'a, u64, std::collections::BTreeMap>), + Flat(std::slice::Iter<'a, u64>), +} + +impl<'a> Iterator for ScoresIter<'a> { + type Item = &'a u64; + + fn next(&mut self) -> Option<&'a u64> { + match self { + Self::Tree(it) => it.next(), + Self::Flat(it) => it.next(), + } + } + + fn size_hint(&self) -> (usize, Option) { + match self { + Self::Tree(it) => it.size_hint(), + Self::Flat(it) => it.size_hint(), + } + } +} + +impl ExactSizeIterator for ScoresIter<'_> {} + /// An iterator over either registry list kind, so [`BeaconState::iter_validators`] /// and [`BeaconState::iter_balances`] can promise one return type across every /// fork despite [`Registry`]'s two underlying list types having different @@ -753,9 +938,9 @@ impl BeaconState { } } - /// 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 @@ -765,13 +950,13 @@ impl BeaconState { if matches!(self, BeaconState::Lean(_)) { return; } - dispatch_state!(self, "apply_pending_mutations", |state| { - state.validators.apply_updates(); - state.balances.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 @@ -781,11 +966,13 @@ impl BeaconState { /// /// [`apply_pending_mutations`]: BeaconState::apply_pending_mutations pub fn has_pending_mutations(&self) -> bool { - match self.registry() { - Some(Registry::Bounded(v, b)) => v.has_pending_updates() || b.has_pending_updates(), - Some(Registry::Progressive(v, b)) => v.has_pending_updates() || b.has_pending_updates(), - None => false, + if matches!(self, BeaconState::Lean(_)) { + return false; } + 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 @@ -795,10 +982,12 @@ impl BeaconState { /// /// Works across every fork that shares a registry list kind with `base`, /// since `List::rebase_on` and `ProgressiveList::rebase_on` each take a - /// `&Self`, not some common trait object. A no-op if either state is - /// lean, or if the two disagree on list kind (a gloas state rebased onto - /// a pre-gloas one, or vice versa): that pairing only happens across the - /// fork boundary itself, where there is no shared tree to reuse anyway. + /// `&Self`, not some common trait object; the other tree-backed fields + /// have one type in every fork. A no-op if either state is lean, and the + /// registry is left alone if the two disagree on list kind (a gloas state + /// rebased onto a pre-gloas one, or vice versa): that pairing only + /// happens across the fork boundary itself, where there is no shared tree + /// to reuse anyway. pub fn rebase_on(&mut self, base: &BeaconState) { let Some(base_registry) = base.registry() else { return; @@ -819,6 +1008,72 @@ impl BeaconState { | (Some(RegistryMut::Bounded(..)), Registry::Progressive(..)) | (Some(RegistryMut::Progressive(..)), Registry::Bounded(..)) => {} } + 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()); + self.rebase_inactivity_scores_on(base); + self.rebase_historical_summaries_on(base); + } + + /// Whether `validators` or `balances` has a write + /// [`Self::apply_pending_mutations`] has not folded into its tree yet. + /// `false` on a lean state. + /// + /// The registry alone, unlike [`Self::has_pending_mutations`], which also + /// counts the other tree-backed fields: the per-slot roots writes stay + /// buffered by design until the next flush, so a caller asking whether + /// the registry is flushed cannot use that one. + pub fn registry_has_pending_updates(&self) -> bool { + match self.registry() { + Some(Registry::Bounded(v, b)) => v.has_pending_updates() || b.has_pending_updates(), + Some(Registry::Progressive(v, b)) => v.has_pending_updates() || b.has_pending_updates(), + None => false, + } + } + + /// Shares `inactivity_scores` with `base`'s where both are tree-backed. + /// Phase0 has no scores, and gloas keeps a flat list with nothing to + /// share, so those pairings (and any mix of the two kinds) do nothing. + fn rebase_inactivity_scores_on(&mut self, base: &BeaconState) { + if let (Ok((_, _, scores)), Ok((_, _, InactivityScoresRef::Tree(base_scores)))) = ( + self.altair_validator_lists_mut(), + base.altair_validator_lists(), + ) { + scores.rebase_on(base_scores); + } + } + + /// Shares `historical_summaries` with `base`'s where both carry it, which + /// is every fork from capella on. Earlier forks have no summaries, so any + /// pairing with one of those does nothing. + fn rebase_historical_summaries_on(&mut self, base: &BeaconState) { + let base_summaries = match base { + BeaconState::Capella(state) => &state.historical_summaries, + BeaconState::Deneb(state) => &state.historical_summaries, + BeaconState::Electra(state) => &state.historical_summaries, + BeaconState::Fulu(state) => &state.historical_summaries, + BeaconState::Gloas(state) => &state.historical_summaries, + BeaconState::Phase0(_) + | BeaconState::Altair(_) + | BeaconState::Bellatrix(_) + | BeaconState::Lean(_) => return, + }; + let summaries = match self { + BeaconState::Capella(state) => &mut state.historical_summaries, + BeaconState::Deneb(state) => &mut state.historical_summaries, + BeaconState::Electra(state) => &mut state.historical_summaries, + BeaconState::Fulu(state) => &mut state.historical_summaries, + BeaconState::Gloas(state) => &mut state.historical_summaries, + BeaconState::Phase0(_) + | BeaconState::Altair(_) + | BeaconState::Bellatrix(_) + | BeaconState::Lean(_) => return, + }; + summaries.rebase_on(base_summaries); } /// Runs `f` over every balance in order through the list's write cursor @@ -920,7 +1175,9 @@ impl BeaconState { } /// The three per-validator lists that exist from altair on, by reference and - /// all at once, as plain slices. + /// all at once: the two participation lists as plain slices, the inactivity + /// scores as an [`InactivityScoresRef`] since a tree-backed list has no + /// slice to hand out. /// /// These cannot join `shared_state_accessors`' lists, since phase0 has /// none of them, and a per-fork projection to a concrete state struct (the @@ -940,7 +1197,7 @@ impl BeaconState { /// kinds: the write side still needs the concrete container type, to grow /// or replace the whole list, which is why [`Self::altair_validator_lists_mut`] /// stays bounded-only. On gloas, element writes go through - /// [`Self::inactivity_scores_mut`], and growing or replacing a list goes + /// [`Self::inactivity_score_mut`], and growing or replacing a list goes /// through a per-fork projection. /// /// Handed back together rather than one accessor per field for the same @@ -950,14 +1207,18 @@ impl BeaconState { /// the rest away with `_`. pub fn altair_validator_lists( &self, - ) -> Result<(&[ParticipationFlags], &[ParticipationFlags], &[u64])> { + ) -> Result<( + &[ParticipationFlags], + &[ParticipationFlags], + InactivityScoresRef<'_>, + )> { dispatch_state_from!( self, "BeaconState::altair_validator_lists", |state| ( &state.previous_epoch_participation[..], &state.current_epoch_participation[..], - &state.inactivity_scores[..], + InactivityScoresRef::from(&state.inactivity_scores), ), carried_by: [Altair, Bellatrix, Capella, Deneb, Electra, Fulu, Gloas], absent_from: [Phase0], @@ -992,7 +1253,7 @@ impl BeaconState { /// registry through a per-fork projection /// (`PendingQueueFields::push_empty_participation_and_inactivity`), writes /// one score in place through - /// [`Self::inactivity_scores_mut`], or replaces a whole list outright + /// [`Self::inactivity_score_mut`], or replaces a whole list outright /// through its own state's own field, the way /// `stf::epoch::gloas::process_participation_flag_updates` does. pub fn altair_validator_lists_mut( @@ -1015,13 +1276,16 @@ impl BeaconState { ) } - /// `inactivity_scores`, mutably, as a plain slice: element writes only, no + /// One of `inactivity_scores`, mutably: an element write only, no /// whole-list replace or length change, which is what lets this include /// gloas where [`Self::altair_validator_lists_mut`] cannot (see that - /// accessor's own doc). A `&mut [u64]` cannot grow or shrink, so handing - /// one out cannot break either list kind's own length invariant, the same - /// reasoning [`libssz_types::ProgressiveList`]'s own `DerefMut` doc gives - /// for allowing element mutation but not resizing through a slice. + /// accessor's own doc). A `&mut u64` cannot grow or shrink either list + /// kind, so handing one out cannot break its length invariant. + /// + /// On a tree-backed list (every fork before gloas) the element is copied + /// into the pending-write map on first touch, whether or not the caller + /// then changes it: write only when the value differs, so an unchanged + /// score leaves the list shared with its parent. /// /// `stf::epoch::altair::process_inactivity_updates` (shared by every fork /// from altair on, gloas included) is the one caller: it only ever writes @@ -1029,11 +1293,17 @@ impl BeaconState { /// `add_validator_to_registry` and `process_participation_flag_updates` /// do instead (see [`Self::altair_validator_lists_mut`]'s own doc for /// where each of those goes). - pub fn inactivity_scores_mut(&mut self) -> Result<&mut [u64]> { + pub fn inactivity_score_mut(&mut self, index: usize) -> Result<&mut u64> { dispatch_state_from!( self, - "BeaconState::inactivity_scores_mut", - |state| &mut state.inactivity_scores[..], + "BeaconState::inactivity_score_mut", + |state| { + let len = state.inactivity_scores.len(); + state + .inactivity_scores + .get_mut(index) + .ok_or(Error::IndexOutOfBounds { index, len })? + }, carried_by: [Altair, Bellatrix, Capella, Deneb, Electra, Fulu, Gloas], absent_from: [Phase0], ) @@ -1041,7 +1311,7 @@ impl BeaconState { /// `previous_epoch_participation` or `current_epoch_participation`, /// mutably and as a plain slice, whichever `current` selects: element - /// writes only, the same contract [`Self::inactivity_scores_mut`]'s own + /// writes only, the same contract [`Self::inactivity_score_mut`]'s own /// doc gives for why that is enough to include gloas where /// [`Self::altair_validator_lists_mut`] cannot. /// diff --git a/crates/common/types/src/beacon/containers/shared.rs b/crates/common/types/src/beacon/containers/shared.rs index b2df90691..0b024f9f1 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, ProgressiveList as TreeProgressiveList}; +use ethlambda_ssz_tree::{List, ProgressiveList as TreeProgressiveList, 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>; /// The gloas validator registry (EIP-7688): unbounded and progressively /// merkleized, tree-backed like [`Validators`] for the same reason (a diff --git a/crates/common/types/src/primitives.rs b/crates/common/types/src/primitives.rs index 0e8cf99fe..4e0b3e9f5 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 7809cf5ae..5f50bbd60 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/ptc.rs b/crates/net/rpc/src/beacon/ptc.rs index c83e47fd1..be80bf330 100644 --- a/crates/net/rpc/src/beacon/ptc.rs +++ b/crates/net/rpc/src/beacon/ptc.rs @@ -570,8 +570,8 @@ mod tests { }; // Distinct roots per slot, so a `dependent_root` read from the wrong // slot shows up. - for (slot, root) in inner.block_roots.iter_mut().enumerate() { - *root = H256::repeat_byte(slot as u8 + 1); + for slot in 0..inner.block_roots.len() { + inner.block_roots[slot] = H256::repeat_byte(slot as u8 + 1); } for index in 0..preset::PTC_WINDOW_LENGTH { inner.ptc_window[index] = vec![index as u64; preset::PTC_SIZE] diff --git a/crates/net/rpc/src/beacon/validator.rs b/crates/net/rpc/src/beacon/validator.rs index 046abb03e..ee2f0bcde 100644 --- a/crates/net/rpc/src/beacon/validator.rs +++ b/crates/net/rpc/src/beacon/validator.rs @@ -892,8 +892,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 d01517ecf..9173f0ac4 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -302,7 +302,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 @@ -411,9 +410,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(), @@ -421,12 +422,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 11e7ec37a..44233b425 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 5faea3087..734439904 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -7157,6 +7157,84 @@ 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; + use ethlambda_types::beacon::containers::HistoricalSummary; + + 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 electra = electra_state_with_validators(600); + for i in 0..4u8 { + let summary = HistoricalSummary { + block_summary_root: H256::from([i; 32]), + state_summary_root: H256::from([i + 1; 32]), + }; + electra.historical_summaries.push(summary).unwrap(); + } + let mut parent = BeaconState::Electra(electra); + // States are keyed by their header's slot and read back by their + // block's, so each state names its own block's slot there. + parent.latest_block_header_mut().slot = 10; + 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.latest_block_header_mut().slot = 11; + *child.balance_mut(0).unwrap() += 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)); + 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())); + let (BeaconState::Electra(decoded_electra), BeaconState::Electra(resident_electra)) = + (decoded.as_ref(), resident.as_ref()) + else { + panic!("both states are electra"); + }; + assert!(!resident_electra.historical_summaries.is_empty()); + assert!( + decoded_electra + .historical_summaries + .ptr_eq(&resident_electra.historical_summaries) + ); + assert_eq!(decoded.to_ssz(), child.to_ssz()); + } + #[test] fn a_cold_beacon_state_folds_every_delta_back_from_its_snapshot() { let backend: Arc = Arc::new(InMemoryBackend::new());