diff --git a/bin/ethlambda/src/era/replay.rs b/bin/ethlambda/src/era/replay.rs index 5f4aaeedb..7ead6df14 100644 --- a/bin/ethlambda/src/era/replay.rs +++ b/bin/ethlambda/src/era/replay.rs @@ -18,7 +18,7 @@ use std::path::Path; use std::time::Instant; -use ethlambda_state_transition::beacon::helpers::accessors::CommitteeCache; +use ethlambda_state_transition::beacon::helpers::accessors::{ActiveBalanceCache, CommitteeCache}; use ethlambda_state_transition::beacon::stf::{self, ExecutionEngine}; use ethlambda_types::beacon::config::Config; use ethlambda_types::beacon::containers::{BeaconState, SignedBeaconBlock}; @@ -53,6 +53,7 @@ pub(super) struct Replay<'a> { engine: ExecutionEngine, /// One for the whole run, as the node holds one across its imports. committees: CommitteeCache, + active_balances: ActiveBalanceCache, max_blocks: Option, carried: Option, } @@ -64,6 +65,7 @@ impl<'a> Replay<'a> { config, engine: ExecutionEngine::valid(), committees: CommitteeCache::default(), + active_balances: ActiveBalanceCache::default(), max_blocks, carried: None, } @@ -166,6 +168,7 @@ impl<'a> Replay<'a> { self.config, &self.engine, &self.committees, + &self.active_balances, ) .map_err(|err| { eyre!( diff --git a/crates/blockchain/state_transition/src/beacon/block_production.rs b/crates/blockchain/state_transition/src/beacon/block_production.rs index cc91f2b32..ac47f526e 100644 --- a/crates/blockchain/state_transition/src/beacon/block_production.rs +++ b/crates/blockchain/state_transition/src/beacon/block_production.rs @@ -38,8 +38,8 @@ use super::bls; use super::config::Config; use super::error::{Error, Result, verify}; use super::helpers::accessors::{ - CommitteeCache, get_beacon_proposer_index, get_block_root, get_block_root_at_slot, - get_current_epoch, get_domain, get_previous_epoch, get_randao_mix, + ActiveBalanceCache, CommitteeCache, get_beacon_proposer_index, get_block_root, + get_block_root_at_slot, get_current_epoch, get_domain, get_previous_epoch, get_randao_mix, }; use super::helpers::electra::{ get_attesting_indices, get_indexed_attestation, is_valid_indexed_attestation, @@ -591,6 +591,7 @@ pub fn assemble_block( config, &ExecutionEngine::valid(), &CommitteeCache::default(), + &ActiveBalanceCache::default(), options, )?; block.state_root = post.hash_tree_root(); @@ -696,6 +697,7 @@ mod tests { &Config::mainnet(), &ExecutionEngine::valid(), &CommitteeCache::default(), + &ActiveBalanceCache::default(), BlockProcessingOptions::default(), ) .unwrap(); @@ -759,6 +761,7 @@ mod tests { &config, &ExecutionEngine::valid(), &CommitteeCache::default(), + &ActiveBalanceCache::default(), skip_randao(), ) .unwrap(); diff --git a/crates/blockchain/state_transition/src/beacon/fork_choice.rs b/crates/blockchain/state_transition/src/beacon/fork_choice.rs index 8ce25d476..9443a0fcb 100644 --- a/crates/blockchain/state_transition/src/beacon/fork_choice.rs +++ b/crates/blockchain/state_transition/src/beacon/fork_choice.rs @@ -4604,8 +4604,18 @@ pub fn on_block( stf::ExecutionEngine::valid() } }; - let transition = - stf::state_transition(&mut state, &signed_block, true, config, &engine, committees); + // The store's own cache, so every block of this chain shares one entry per + // epoch; see `ActiveBalanceCacheExt`. + let active_balances = store.active_balance_cache(); + let transition = stf::state_transition( + &mut state, + &signed_block, + true, + config, + &engine, + committees, + &active_balances, + ); // `optimistic-sync.md`: a block deemed `INVALIDATED` MUST NOT be included // in the canonical chain. That is stated here, on the verdict, rather than @@ -5278,7 +5288,7 @@ mod tests { use super::*; use crate::beacon::bls; use crate::beacon::containers::BeaconBlockHeader; - use crate::beacon::helpers::accessors; + use crate::beacon::helpers::accessors::{self, ActiveBalanceCache}; use crate::beacon::helpers::misc::compute_signing_root; use crate::beacon::helpers::test_state; use crate::beacon::primitives::BlsSignature; @@ -6440,6 +6450,7 @@ mod tests { &config, &stf::ExecutionEngine::valid(), &trial_committees, + &ActiveBalanceCache::default(), ) .expect("the drafted block must transition cleanly"); let state_root = trial_state.hash_tree_root(); diff --git a/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs b/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs index 6abaed5af..b4621d542 100644 --- a/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs +++ b/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs @@ -26,7 +26,8 @@ use super::bls; use super::config::Config; use super::error::{Error, Result, verify}; use super::helpers::accessors::{ - CommitteeCache, get_beacon_proposer_index, get_current_epoch, get_randao_mix, + ActiveBalanceCache, CommitteeCache, get_beacon_proposer_index, get_current_epoch, + get_randao_mix, }; use super::helpers::gloas::{ can_builder_cover_bid, get_indexed_payload_attestation, get_ptc, gloas_state_ref, @@ -407,6 +408,7 @@ pub fn assemble_gloas_block_on_bid( &block, config, &CommitteeCache::default(), + &ActiveBalanceCache::default(), options, )?; block.state_root = post.hash_tree_root(); @@ -532,6 +534,7 @@ pub fn assemble_gloas_block( &block, config, &CommitteeCache::default(), + &ActiveBalanceCache::default(), options, )?; block.state_root = post.hash_tree_root(); @@ -747,6 +750,7 @@ pub mod test_support { block, &config(), &CommitteeCache::default(), + &ActiveBalanceCache::default(), BlockProcessingOptions::default(), ) .unwrap(); @@ -910,6 +914,7 @@ mod gloas_block_production_tests { &config(), &ExecutionEngine::valid(), &CommitteeCache::default(), + &ActiveBalanceCache::default(), BlockProcessingOptions::default(), ) .unwrap(); @@ -1042,6 +1047,7 @@ mod gloas_block_production_tests { &produced.block, &config(), &CommitteeCache::default(), + &ActiveBalanceCache::default(), skip, ) .unwrap(); diff --git a/crates/blockchain/state_transition/src/beacon/helpers/accessors.rs b/crates/blockchain/state_transition/src/beacon/helpers/accessors.rs index fd92b0370..1b33d655b 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/accessors.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/accessors.rs @@ -11,7 +11,9 @@ use std::sync::Arc; // and so on), so every existing import of the type this module used to define // is unaffected by its move to `ethlambda-storage`; see [`CommitteeCacheExt`] // below for what this crate still contributes. -pub use ethlambda_storage::{CommitteeCache, Lookup, ShufflingKey}; +pub use ethlambda_storage::{ + ActiveBalanceCache, ActiveBalanceKey, CommitteeCache, Lookup, ShufflingKey, +}; use crate::beacon::config::Config; use crate::beacon::constants; @@ -301,6 +303,96 @@ impl CommitteeCacheExt for CommitteeCache { } } +/// The key `state`'s total active balance is cached under, or `None` if +/// `state` cannot name the block that fixes it: the state has not advanced +/// past that block's slot (the genesis state, asked about its own epoch), or +/// the slot has fallen out of its `SLOTS_PER_HISTORICAL_ROOT` window. `None` +/// means the lookup is computed for its caller alone and not cached. +/// +/// The total for a state in epoch `E` sums the effective balances of the +/// validators active at `E`, and both inputs are fixed once epoch `E - 1` +/// ends. Effective balances are written only by `process_effective_balance_updates`, +/// at the end of `E - 1`; an epoch's update is the last writer before `E`'s +/// states exist. The active set moves only through `activation_epoch` and +/// `exit_epoch`, and every assignment goes through `compute_activation_exit_epoch`, +/// at least `MAX_SEED_LOOKAHEAD` epochs ahead, so none can land in `E` for `E` +/// itself. Slashing sets `slashed` and queues an exit but leaves effective +/// balances alone, and the spec's total keeps slashed validators. Deposits +/// append validators that are not yet active, and the Electra upgrade zeroes +/// only never-activated validators. So the block root at the last slot of +/// `E - 1` identifies the history that determines the total: two states +/// agreeing on it agree on the total, however much they disagree after it. +/// +/// That is one epoch later than [`shuffling_key`]'s root (the last slot of +/// `E - 2`), because the shuffle's inputs are fixed `MIN_SEED_LOOKAHEAD` epochs +/// ahead of use and the total's are not: an effective balance written at the +/// end of `E - 1` is part of the total for `E`, so a key rooted at `E - 2` +/// would let two branches that diverge during `E - 1` share one entry. +/// +/// # Epoch 0 +/// +/// There is no epoch before it, so it takes the genesis block, at slot 0, as +/// its deciding block, as [`shuffling_key`] does for its first epochs. Nothing +/// after genesis can reach either input within epoch 0 (the effective-balance +/// update at its end writes epoch 1's), and a state from a different genesis +/// has a different genesis block root. +/// +/// # Callers must be block processing +/// +/// Between `process_effective_balance_updates` and the slot increment that +/// follows it, a state is still in epoch `E - 1` but already carries epoch +/// `E`'s effective balances, so its total would not match a key rooted at +/// `E - 2`'s end. The only callers routed through the cache are block +/// processing's (attestations, sync aggregate), which never run in that +/// window: a block is processed on a state that `process_slots` has already +/// advanced to the block's slot. Epoch processing keeps calling the +/// uncached [`get_total_active_balance`]. +fn active_balance_key(state: &BeaconState) -> Option { + let epoch = get_current_epoch(state); + let decision_slot = compute_start_slot_at_epoch(epoch).saturating_sub(1); + let decision_root = get_block_root_at_slot(state, decision_slot).ok()?; + Some(ActiveBalanceKey { + epoch, + decision_root, + }) +} + +/// Extends `ethlambda-storage`'s [`ActiveBalanceCache`] with the consensus +/// logic that keys and computes its entries, for the same reason +/// [`CommitteeCacheExt`] lives here and not in `ethlambda-storage`. +pub trait ActiveBalanceCacheExt { + /// `get_total_active_balance(state)`, computed once per key and then + /// served to every later caller naming the same one. Equal to the + /// uncached function for every state it is given; see [`active_balance_key`] + /// for why, and for who may call it. + fn total_active_balance(&self, state: &BeaconState) -> Result; +} + +impl ActiveBalanceCacheExt for ActiveBalanceCache { + fn total_active_balance(&self, state: &BeaconState) -> Result { + let Some(key) = active_balance_key(state) else { + crate::metrics::inc_total_active_balance_lookups("unkeyable"); + return get_total_active_balance(state); + }; + + let (total, lookup) = self.get_or_compute(key, || compute_total_active_balance(state)); + crate::metrics::inc_total_active_balance_lookups(match lookup { + Lookup::Hit => "hit", + Lookup::Miss => "miss", + }); + // The cross-check the key's soundness argument rests on: a stale hit + // would mean some writer moved an input mid-epoch. + if lookup == Lookup::Hit { + debug_assert_eq!( + total, + compute_total_active_balance(state), + "stale total active balance cache entry" + ); + } + Ok(total) + } +} + /// The committee at `slot` with index `index`. /// /// One epoch's active set is shuffled once and then split across every slot and @@ -414,9 +506,27 @@ pub fn get_total_balance(state: &BeaconState, indices: &[ValidatorIndex]) -> Res } /// The combined effective balance of the currently active validators. +/// +/// One in-order pass over the registry, summing as it goes: the same value as +/// [`get_total_balance`] over [`get_active_validator_indices`] (same +/// saturating sum, same one-increment floor), without the intermediate index +/// list or a tree descent per active validator. `state.iter_validators()` +/// walks the leaves, whereas `state.validator(i)` descends from the root each +/// time. pub fn get_total_active_balance(state: &BeaconState) -> Result { - let indices = get_active_validator_indices(state, get_current_epoch(state)); - get_total_balance(state, &indices) + Ok(compute_total_active_balance(state)) +} + +/// [`get_total_active_balance`]'s body, without the `Result` it never needed. +fn compute_total_active_balance(state: &BeaconState) -> Gwei { + let epoch = get_current_epoch(state); + let total = state + .iter_validators() + .filter(|validator| is_active_validator(validator, epoch)) + .fold(0, |sum: Gwei, validator| { + sum.saturating_add(validator.effective_balance) + }); + total.max(preset::EFFECTIVE_BALANCE_INCREMENT) } /// The signing domain for `domain_type` at `epoch`, or at the current epoch when @@ -665,6 +775,159 @@ mod tests { ); } + /// SplitMix64: a tiny deterministic generator, so the randomized tests + /// below need no dependency. + struct SplitMix64(u64); + + impl SplitMix64 { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + } + + /// A state at `epoch` whose validators get random activation and exit + /// epochs around it and random effective balances. + fn random_registry_state(rng: &mut SplitMix64, count: usize, epoch: Epoch) -> BeaconState { + let mut state = with_validators(count); + *state.slot_mut() = compute_start_slot_at_epoch(epoch); + for index in 0..count { + let validator = state.validator_mut(index as ValidatorIndex).unwrap(); + validator.activation_epoch = rng.next() % (epoch + 3); + validator.exit_epoch = match rng.next() % 3 { + 0 => constants::FAR_FUTURE_EPOCH, + _ => rng.next() % (epoch + 3), + }; + validator.effective_balance = match rng.next() % 4 { + 0 => 0, + 1 => u64::MAX - rng.next() % 4, + _ => (rng.next() % 64) * preset::EFFECTIVE_BALANCE_INCREMENT, + }; + } + state.apply_pending_mutations(); + state + } + + /// The one-pass total is the spec's `get_total_balance` over the active + /// indices, including its saturation and its floor. + #[test] + fn the_one_pass_total_matches_the_spec_formulation() { + let mut rng = SplitMix64(0x5EED); + for round in 0..64 { + let count = (rng.next() % 40) as usize; + let epoch = rng.next() % 6; + let state = random_registry_state(&mut rng, count, epoch); + let indices = get_active_validator_indices(&state, get_current_epoch(&state)); + assert_eq!( + get_total_active_balance(&state).unwrap(), + get_total_balance(&state, &indices).unwrap(), + "round {round}" + ); + } + } + + /// An all-zero registry hits the floor, not zero. + #[test] + fn the_one_pass_total_is_floored_for_a_zero_registry() { + let mut state = with_validators(8); + for index in 0..8 { + state.validator_mut(index).unwrap().effective_balance = 0; + } + state.apply_pending_mutations(); + assert_eq!( + get_total_active_balance(&state).unwrap(), + preset::EFFECTIVE_BALANCE_INCREMENT + ); + } + + /// A state at the first slot of `epoch` whose every block root is `root`. + fn state_at_epoch_with_root(epoch: Epoch, root: u8) -> BeaconState { + let mut state = with_validators(16); + *state.slot_mut() = compute_start_slot_at_epoch(epoch); + for slot in 0..preset::SLOTS_PER_HISTORICAL_ROOT { + state.block_roots_mut()[slot] = Root::repeat_byte(root); + } + state + } + + /// A lookup returns the spec value and the second one is a hit. + #[test] + fn the_active_balance_cache_serves_the_spec_value() { + let state = state_at_epoch_with_root(3, 1); + let cache = ActiveBalanceCache::default(); + let expected = get_total_active_balance(&state).unwrap(); + + assert_eq!(cache.total_active_balance(&state).unwrap(), expected); + let key = active_balance_key(&state).unwrap(); + assert_eq!(cache.get(key), Some(expected)); + assert_eq!(cache.total_active_balance(&state).unwrap(), expected); + } + + /// Two sibling states in one epoch that disagree on the deciding block + /// keep separate entries, each with its own registry's total. + #[test] + fn sibling_states_with_different_decision_roots_get_different_entries() { + let cache = ActiveBalanceCache::default(); + let a = state_at_epoch_with_root(3, 1); + let mut b = state_at_epoch_with_root(3, 2); + // Sibling `b` has a validator the fork `a` never saw. + b.validator_mut(0).unwrap().effective_balance = 0; + b.apply_pending_mutations(); + + let total_a = cache.total_active_balance(&a).unwrap(); + let total_b = cache.total_active_balance(&b).unwrap(); + + assert_ne!(total_a, total_b); + assert_eq!(total_a, get_total_active_balance(&a).unwrap()); + assert_eq!(total_b, get_total_active_balance(&b).unwrap()); + assert_eq!(cache.get(active_balance_key(&a).unwrap()), Some(total_a)); + assert_eq!(cache.get(active_balance_key(&b).unwrap()), Some(total_b)); + } + + /// Across an epoch boundary the key changes, so the lookup misses and + /// fills a new entry instead of serving the old epoch's total. + #[test] + fn crossing_an_epoch_boundary_misses_and_refills() { + let cache = ActiveBalanceCache::default(); + let mut state = state_at_epoch_with_root(3, 1); + let before = cache.total_active_balance(&state).unwrap(); + let old_key = active_balance_key(&state).unwrap(); + + // The effective-balance update at the boundary, then the new epoch + // with its own deciding root. + state.validator_mut(0).unwrap().effective_balance = 0; + state.apply_pending_mutations(); + *state.slot_mut() = compute_start_slot_at_epoch(4); + let last_slot = + (compute_start_slot_at_epoch(4) - 1) as usize % preset::SLOTS_PER_HISTORICAL_ROOT; + state.block_roots_mut()[last_slot] = Root::repeat_byte(9); + + let new_key = active_balance_key(&state).unwrap(); + assert_ne!(old_key, new_key); + let after = cache.total_active_balance(&state).unwrap(); + assert_eq!(after, get_total_active_balance(&state).unwrap()); + assert_ne!(before, after); + assert_eq!(cache.get(old_key), Some(before)); + assert_eq!(cache.get(new_key), Some(after)); + } + + /// The genesis state cannot name the deciding block, so it is computed + /// for its caller alone and not cached. + #[test] + fn an_unkeyable_state_is_computed_but_not_cached() { + let mut state = with_validators(8); + *state.slot_mut() = 0; + assert!(active_balance_key(&state).is_none()); + let cache = ActiveBalanceCache::default(); + assert_eq!( + cache.total_active_balance(&state).unwrap(), + get_total_active_balance(&state).unwrap() + ); + } + #[test] fn proposer_is_drawn_from_the_active_set() { let state = crate::beacon::helpers::test_state::with_validators(32); diff --git a/crates/blockchain/state_transition/src/beacon/helpers/altair.rs b/crates/blockchain/state_transition/src/beacon/helpers/altair.rs index 5b57165d1..d6404e558 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/altair.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/altair.rs @@ -204,11 +204,14 @@ pub fn compute_sync_committee_period(epoch: Epoch) -> u64 { /// stake actually earned it rather than assuming every attestation splits the /// same fixed fraction. pub fn get_base_reward_per_increment(state: &BeaconState) -> Result { - let total_active_balance = get_total_active_balance(state)?; - Ok( - preset::EFFECTIVE_BALANCE_INCREMENT * preset::BASE_REWARD_FACTOR - / integer_squareroot(total_active_balance), - ) + Ok(base_reward_per_increment(get_total_active_balance(state)?)) +} + +/// [`get_base_reward_per_increment`] for a total active balance the caller +/// already holds, such as one served by an `ActiveBalanceCache`. +pub fn base_reward_per_increment(total_active_balance: Gwei) -> Gwei { + preset::EFFECTIVE_BALANCE_INCREMENT * preset::BASE_REWARD_FACTOR + / integer_squareroot(total_active_balance) } /// The base reward for the validator at `index`. diff --git a/crates/blockchain/state_transition/src/beacon/stf/altair.rs b/crates/blockchain/state_transition/src/beacon/stf/altair.rs index 14017a83b..2b33e79be 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/altair.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/altair.rs @@ -31,11 +31,12 @@ use crate::beacon::constants; use crate::beacon::containers::{BeaconState, altair, phase0}; use crate::beacon::error::{Error, Result, verify}; use crate::beacon::helpers::accessors::{ - CommitteeCache, CommitteeCacheExt, get_beacon_proposer_index, get_block_root_at_slot, - get_current_epoch, get_domain, get_previous_epoch, get_total_active_balance, + ActiveBalanceCache, ActiveBalanceCacheExt, CommitteeCache, CommitteeCacheExt, + get_beacon_proposer_index, get_block_root_at_slot, get_current_epoch, get_domain, + get_previous_epoch, }; use crate::beacon::helpers::altair::{ - add_flag, get_attestation_participation_flag_indices, get_base_reward_per_increment, has_flag, + add_flag, base_reward_per_increment, get_attestation_participation_flag_indices, has_flag, }; use crate::beacon::helpers::attestation::{get_indexed_attestation, is_valid_indexed_attestation}; use crate::beacon::helpers::misc::{compute_epoch_at_slot, compute_signing_root}; @@ -82,6 +83,7 @@ pub fn process_attestation( state: &mut BeaconState, attestation: &phase0::Attestation, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, ) -> Result<()> { let data = attestation.data; let current_epoch = get_current_epoch(state); @@ -160,7 +162,8 @@ pub fn process_attestation( }; // Hoisted: see the comment on the same line in `electra::process_attestation`. - let base_reward_per_increment = get_base_reward_per_increment(state)?; + let base_reward_per_increment = + base_reward_per_increment(active_balances.total_active_balance(state)?); let mut proposer_reward_numerator: Gwei = 0; let mut updates: Vec<(ValidatorIndex, ParticipationFlags)> = Vec::new(); @@ -175,6 +178,10 @@ pub fn process_attestation( })?; let mut new_flags: ParticipationFlags = 0; + // The attester's validator is read at most once, on the first flag + // it newly earns: each read is a tree descent, and the effective + // balance cannot change inside this read-only phase. + let mut attester_increments: Option = None; for &flag_index in &participation_flag_indices { if has_flag(current_flags, flag_index) { continue; @@ -186,8 +193,15 @@ pub fn process_attestation( // the result is bit-identical. See `electra::process_attestation` // for why the hoist is not a tidy-up but the difference between // importing a block at mainnet scale and not. - let increments = - state.validator(index)?.effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + let increments = match attester_increments { + Some(increments) => increments, + None => { + let increments = state.validator(index)?.effective_balance + / preset::EFFECTIVE_BALANCE_INCREMENT; + attester_increments = Some(increments); + increments + } + }; let reward = (increments * base_reward_per_increment) .checked_mul(weight) .ok_or(Error::ArithmeticOverflow( @@ -289,6 +303,7 @@ pub fn process_attestation( pub fn process_sync_aggregate( state: &mut BeaconState, sync_aggregate: &altair::SyncAggregate, + active_balances: &ActiveBalanceCache, ) -> Result<()> { let committee_bits = &sync_aggregate.sync_committee_bits; @@ -358,9 +373,9 @@ pub fn process_sync_aggregate( }) .collect::>>()?; - let total_active_increments = - get_total_active_balance(state)? / preset::EFFECTIVE_BALANCE_INCREMENT; - let total_base_rewards = get_base_reward_per_increment(state)? + let total_active_balance = active_balances.total_active_balance(state)?; + let total_active_increments = total_active_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + let total_base_rewards = base_reward_per_increment(total_active_balance) .checked_mul(total_active_increments) .ok_or(Error::ArithmeticOverflow( "get_base_reward_per_increment(state) * total_active_increments", @@ -407,7 +422,8 @@ mod tests { use super::*; use crate::beacon::containers::altair::SyncCommittee; use crate::beacon::containers::shared::{AttestationData, Checkpoint, Validator}; - use crate::beacon::helpers::accessors::get_beacon_committee; + use crate::beacon::helpers::accessors::{get_beacon_committee, get_total_active_balance}; + use crate::beacon::helpers::altair::get_base_reward_per_increment; use crate::beacon::primitives::{ BLS_SIGNATURE_SIZE, BlsPubkey, BlsSignature, Bytes32, HashTreeRoot as _, Root, }; @@ -577,7 +593,13 @@ mod tests { let proposer_index = get_beacon_proposer_index(&state).unwrap(); let balance_before = state.balance(proposer_index).unwrap(); - process_attestation(&mut state, &attestation, &CommitteeCache::default()).unwrap(); + process_attestation( + &mut state, + &attestation, + &CommitteeCache::default(), + &ActiveBalanceCache::default(), + ) + .unwrap(); let (previous_epoch_participation, _, _) = state.altair_validator_lists().unwrap(); for &index in &committee { @@ -607,7 +629,13 @@ mod tests { // so reprocessing the identical attestation must grant nothing new, and // the proposer's balance must not move. let balance_before_replay = state.balance(proposer_index).unwrap(); - process_attestation(&mut state, &attestation, &CommitteeCache::default()).unwrap(); + process_attestation( + &mut state, + &attestation, + &CommitteeCache::default(), + &ActiveBalanceCache::default(), + ) + .unwrap(); let balance_after_replay = state.balance(proposer_index).unwrap(); assert_eq!( balance_before_replay, balance_after_replay, @@ -683,7 +711,8 @@ mod tests { .map(|index| state.balance(index).unwrap()) .collect(); - process_sync_aggregate(&mut state, &sync_aggregate).unwrap(); + process_sync_aggregate(&mut state, &sync_aggregate, &ActiveBalanceCache::default()) + .unwrap(); // Independently derived expected rewards, sharing only the // already-tested building blocks (`get_total_active_balance`, @@ -748,7 +777,8 @@ mod tests { sync_committee_signature: BlsSignature::default(), }; assert!( - process_sync_aggregate(&mut state.clone(), &wrong).is_err(), + process_sync_aggregate(&mut state.clone(), &wrong, &ActiveBalanceCache::default()) + .is_err(), "the all-zero signature is not the point at infinity, so this must be rejected" ); @@ -757,7 +787,7 @@ mod tests { sync_committee_signature: g2_point_at_infinity(), }; let balance_before = state.balance(0).unwrap(); - process_sync_aggregate(&mut state, &correct).unwrap(); + process_sync_aggregate(&mut state, &correct, &ActiveBalanceCache::default()).unwrap(); // The sole validator holds every seat and none of them participated, // so it is penalized once per seat and the proposer (itself, the only diff --git a/crates/blockchain/state_transition/src/beacon/stf/bellatrix.rs b/crates/blockchain/state_transition/src/beacon/stf/bellatrix.rs index 3a9c9de85..e9287513e 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/bellatrix.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/bellatrix.rs @@ -33,7 +33,9 @@ use crate::beacon::config::Config; use crate::beacon::constants; use crate::beacon::containers::{BeaconState, bellatrix}; use crate::beacon::error::{Error, Result, verify}; -use crate::beacon::helpers::accessors::{CommitteeCache, get_current_epoch, get_randao_mix}; +use crate::beacon::helpers::accessors::{ + ActiveBalanceCache, CommitteeCache, get_current_epoch, get_randao_mix, +}; use crate::beacon::preset; use crate::beacon::primitives::{ Bytes32, ExecutionAddress, ExecutionBlockHash, HashTreeRoot as _, Root, Slot, Uint256, @@ -67,6 +69,7 @@ pub fn process_block( config: &Config, engine: &ExecutionEngine, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, options: super::block::BlockProcessingOptions, ) -> Result<()> { super::block::process_block_header( @@ -90,8 +93,9 @@ pub fn process_block( &block.body.voluntary_exits, config, committees, + active_balances, )?; - super::altair::process_sync_aggregate(state, &block.body.sync_aggregate)?; + super::altair::process_sync_aggregate(state, &block.body.sync_aggregate, active_balances)?; Ok(()) } diff --git a/crates/blockchain/state_transition/src/beacon/stf/block.rs b/crates/blockchain/state_transition/src/beacon/stf/block.rs index 6831c5460..dcaa32684 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/block.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/block.rs @@ -24,7 +24,8 @@ use crate::beacon::containers::{self, BeaconBlockHeader, BeaconState, Eth1Data, use crate::beacon::error::{Result, verify}; use crate::beacon::hash::hash; use crate::beacon::helpers::accessors::{ - CommitteeCache, get_beacon_proposer_index, get_current_epoch, get_domain, get_randao_mix, + ActiveBalanceCache, CommitteeCache, get_beacon_proposer_index, get_current_epoch, get_domain, + get_randao_mix, }; use crate::beacon::helpers::math::xor; use crate::beacon::helpers::misc::compute_signing_root; @@ -46,36 +47,82 @@ pub fn process_block( config: &Config, engine: &ExecutionEngine, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, options: BlockProcessingOptions, ) -> Result<()> { match signed_block { - containers::SignedBeaconBlock::Phase0(signed) => { - process_block_phase0(state, &signed.message, config, committees, options) - } - containers::SignedBeaconBlock::Altair(signed) => { - process_block_altair(state, &signed.message, config, committees, options) - } - containers::SignedBeaconBlock::Bellatrix(signed) => { - bellatrix::process_block(state, &signed.message, config, engine, committees, options) - } - containers::SignedBeaconBlock::Capella(signed) => { - capella::process_block(state, &signed.message, config, engine, committees, options) - } - containers::SignedBeaconBlock::Deneb(signed) => { - deneb::process_block(state, &signed.message, config, engine, committees, options) - } - containers::SignedBeaconBlock::Electra(signed) => { - electra::process_block(state, &signed.message, config, engine, committees, options) - } - containers::SignedBeaconBlock::Fulu(signed) => { - fulu::process_block(state, &signed.message, config, engine, committees, options) - } + containers::SignedBeaconBlock::Phase0(signed) => process_block_phase0( + state, + &signed.message, + config, + committees, + active_balances, + options, + ), + containers::SignedBeaconBlock::Altair(signed) => process_block_altair( + state, + &signed.message, + config, + committees, + active_balances, + options, + ), + containers::SignedBeaconBlock::Bellatrix(signed) => bellatrix::process_block( + state, + &signed.message, + config, + engine, + committees, + active_balances, + options, + ), + containers::SignedBeaconBlock::Capella(signed) => capella::process_block( + state, + &signed.message, + config, + engine, + committees, + active_balances, + options, + ), + containers::SignedBeaconBlock::Deneb(signed) => deneb::process_block( + state, + &signed.message, + config, + engine, + committees, + active_balances, + options, + ), + containers::SignedBeaconBlock::Electra(signed) => electra::process_block( + state, + &signed.message, + config, + engine, + committees, + active_balances, + options, + ), + containers::SignedBeaconBlock::Fulu(signed) => fulu::process_block( + state, + &signed.message, + config, + engine, + committees, + active_balances, + options, + ), // ePBS (EIP-7732) restructures block processing around a builder's // bid rather than an embedded payload: a gloas block carries no // payload for `engine` to answer for, so this arm never reaches it. - containers::SignedBeaconBlock::Gloas(signed) => { - gloas::process_block(state, &signed.message, config, committees, options) - } + containers::SignedBeaconBlock::Gloas(signed) => gloas::process_block( + state, + &signed.message, + config, + committees, + active_balances, + options, + ), containers::SignedBeaconBlock::Lean(_) => { crate::beacon::lean_block_unreachable("process_block") } @@ -89,6 +136,7 @@ pub fn process_block_phase0( block: &phase0::BeaconBlock, config: &Config, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, options: BlockProcessingOptions, ) -> Result<()> { process_block_header( @@ -109,6 +157,7 @@ pub fn process_block_phase0( &block.body.voluntary_exits, config, committees, + active_balances, )?; Ok(()) } @@ -124,6 +173,7 @@ pub fn process_block_altair( block: &altair::BeaconBlock, config: &Config, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, options: BlockProcessingOptions, ) -> Result<()> { process_block_header( @@ -144,8 +194,9 @@ pub fn process_block_altair( &block.body.voluntary_exits, config, committees, + active_balances, )?; - super::altair::process_sync_aggregate(state, &block.body.sync_aggregate)?; + super::altair::process_sync_aggregate(state, &block.body.sync_aggregate, active_balances)?; Ok(()) } diff --git a/crates/blockchain/state_transition/src/beacon/stf/capella.rs b/crates/blockchain/state_transition/src/beacon/stf/capella.rs index 2c075659b..f773d6da7 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/capella.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/capella.rs @@ -48,7 +48,9 @@ use crate::beacon::containers::shared::{Deposit, ProposerSlashing, SignedVolunta use crate::beacon::containers::{BeaconState, capella, phase0}; use crate::beacon::error::{Error, Result, verify}; use crate::beacon::hash::hash; -use crate::beacon::helpers::accessors::{CommitteeCache, get_current_epoch, get_randao_mix}; +use crate::beacon::helpers::accessors::{ + ActiveBalanceCache, CommitteeCache, get_current_epoch, get_randao_mix, +}; use crate::beacon::helpers::capella::{ is_fully_withdrawable_validator, is_partially_withdrawable_validator, withdrawal_address, }; @@ -76,6 +78,7 @@ pub fn process_block( config: &Config, engine: &ExecutionEngine, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, options: super::block::BlockProcessingOptions, ) -> Result<()> { super::block::process_block_header( @@ -99,8 +102,9 @@ pub fn process_block( &block.body.bls_to_execution_changes, config, committees, + active_balances, )?; - super::altair::process_sync_aggregate(state, &block.body.sync_aggregate)?; + super::altair::process_sync_aggregate(state, &block.body.sync_aggregate, active_balances)?; Ok(()) } @@ -364,6 +368,7 @@ pub fn process_operations( bls_to_execution_changes: &[capella::SignedBLSToExecutionChange], config: &Config, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, ) -> Result<()> { super::operations::process_operations( state, @@ -374,6 +379,7 @@ pub fn process_operations( voluntary_exits, config, committees, + active_balances, )?; for signed_change in bls_to_execution_changes { process_bls_to_execution_change(state, signed_change, config)?; diff --git a/crates/blockchain/state_transition/src/beacon/stf/deneb.rs b/crates/blockchain/state_transition/src/beacon/stf/deneb.rs index 895c033fa..72b5ac946 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/deneb.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/deneb.rs @@ -36,10 +36,11 @@ use crate::beacon::containers::{BeaconState, deneb, phase0}; use crate::beacon::error::{Error, Result, verify}; use crate::beacon::hash::hash; use crate::beacon::helpers::accessors::{ - CommitteeCache, CommitteeCacheExt, get_beacon_proposer_index, get_block_root, - get_block_root_at_slot, get_current_epoch, get_previous_epoch, get_randao_mix, + ActiveBalanceCache, ActiveBalanceCacheExt, CommitteeCache, CommitteeCacheExt, + get_beacon_proposer_index, get_block_root, get_block_root_at_slot, get_current_epoch, + get_previous_epoch, get_randao_mix, }; -use crate::beacon::helpers::altair::{add_flag, get_base_reward_per_increment, has_flag}; +use crate::beacon::helpers::altair::{add_flag, base_reward_per_increment, has_flag}; use crate::beacon::helpers::attestation::{get_indexed_attestation, is_valid_indexed_attestation}; use crate::beacon::helpers::math::integer_squareroot; use crate::beacon::helpers::misc::{compute_domain, compute_epoch_at_slot, compute_signing_root}; @@ -77,6 +78,7 @@ pub fn process_block( config: &Config, engine: &ExecutionEngine, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, options: super::block::BlockProcessingOptions, ) -> Result<()> { super::block::process_block_header( @@ -106,8 +108,9 @@ pub fn process_block( &block.body.bls_to_execution_changes, config, committees, + active_balances, )?; - super::altair::process_sync_aggregate(state, &block.body.sync_aggregate)?; + super::altair::process_sync_aggregate(state, &block.body.sync_aggregate, active_balances)?; Ok(()) } @@ -148,6 +151,7 @@ fn process_operations( bls_to_execution_changes: &[SignedBLSToExecutionChange], config: &Config, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, ) -> Result<()> { let outstanding = state .eth1_data() @@ -168,7 +172,7 @@ fn process_operations( super::operations::process_attester_slashing(state, attester_slashing, config)?; } for attestation in attestations { - process_attestation(state, attestation, committees)?; + process_attestation(state, attestation, committees, active_balances)?; } for deposit in deposits { super::operations::process_deposit(state, deposit, config)?; @@ -282,6 +286,7 @@ pub fn process_attestation( state: &mut BeaconState, attestation: &phase0::Attestation, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, ) -> Result<()> { let data = attestation.data; let current_epoch = get_current_epoch(state); @@ -357,7 +362,8 @@ pub fn process_attestation( }; // Hoisted: see the comment on the same line in `electra::process_attestation`. - let base_reward_per_increment = get_base_reward_per_increment(state)?; + let base_reward_per_increment = + base_reward_per_increment(active_balances.total_active_balance(state)?); let mut proposer_reward_numerator: Gwei = 0; let mut updates: Vec<(ValidatorIndex, ParticipationFlags)> = Vec::new(); @@ -372,6 +378,10 @@ pub fn process_attestation( })?; let mut new_flags: ParticipationFlags = 0; + // The attester's validator is read at most once, on the first flag + // it newly earns: each read is a tree descent, and the effective + // balance cannot change inside this read-only phase. + let mut attester_increments: Option = None; for &flag_index in &participation_flag_indices { if has_flag(current_flags, flag_index) { continue; @@ -383,8 +393,15 @@ pub fn process_attestation( // the result is bit-identical. See `electra::process_attestation` // for why the hoist is not a tidy-up but the difference between // importing a block at mainnet scale and not. - let increments = - state.validator(index)?.effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + let increments = match attester_increments { + Some(increments) => increments, + None => { + let increments = state.validator(index)?.effective_balance + / preset::EFFECTIVE_BALANCE_INCREMENT; + attester_increments = Some(increments); + increments + } + }; let reward = (increments * base_reward_per_increment) .checked_mul(weight) .ok_or(Error::ArithmeticOverflow( diff --git a/crates/blockchain/state_transition/src/beacon/stf/electra.rs b/crates/blockchain/state_transition/src/beacon/stf/electra.rs index bc7bf0224..9b3519caf 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/electra.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/electra.rs @@ -54,10 +54,11 @@ use crate::beacon::containers::shared::{ use crate::beacon::containers::{BeaconState, capella, deneb, electra, fulu}; use crate::beacon::error::{Error, Result, verify}; use crate::beacon::helpers::accessors::{ - CommitteeCache, CommitteeCacheExt, get_beacon_proposer_index, get_block_root, - get_block_root_at_slot, get_current_epoch, get_previous_epoch, get_randao_mix, + ActiveBalanceCache, ActiveBalanceCacheExt, CommitteeCache, CommitteeCacheExt, + get_beacon_proposer_index, get_block_root, get_block_root_at_slot, get_current_epoch, + get_previous_epoch, get_randao_mix, }; -use crate::beacon::helpers::altair::{add_flag, get_base_reward_per_increment, has_flag}; +use crate::beacon::helpers::altair::{add_flag, base_reward_per_increment, has_flag}; use crate::beacon::helpers::capella::withdrawal_address; use crate::beacon::helpers::electra::{ compute_exit_epoch_and_update_churn, get_committee_indices, @@ -639,6 +640,7 @@ pub fn process_attestation( state: &mut BeaconState, attestation: &electra::Attestation, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, ) -> Result<()> { let data = attestation.data; let current_epoch = get_current_epoch(state); @@ -743,7 +745,8 @@ pub fn process_attestation( // finished one. That was measured on a live mainnet run: the chain actor // sat at 98% CPU inside `get_active_validator_indices`, reached through // exactly this line, and the node's clock stopped advancing. - let base_reward_per_increment = get_base_reward_per_increment(state)?; + let base_reward_per_increment = + base_reward_per_increment(active_balances.total_active_balance(state)?); let mut proposer_reward_numerator: Gwei = 0; let mut updates: Vec<(ValidatorIndex, ParticipationFlags)> = Vec::new(); @@ -758,6 +761,10 @@ pub fn process_attestation( })?; let mut new_flags: ParticipationFlags = 0; + // The attester's validator is read at most once, on the first flag + // it newly earns: each read is a tree descent, and the effective + // balance cannot change inside this read-only phase. + let mut attester_increments: Option = None; for &flag_index in &participation_flag_indices { if has_flag(current_flags, flag_index) { continue; @@ -767,8 +774,15 @@ pub fn process_attestation( // `get_base_reward(state, index)` inlined against the hoisted // per-increment value above, keeping the helper's own order of // operations so the result is bit-identical. - let increments = - state.validator(index)?.effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + let increments = match attester_increments { + Some(increments) => increments, + None => { + let increments = state.validator(index)?.effective_balance + / preset::EFFECTIVE_BALANCE_INCREMENT; + attester_increments = Some(increments); + increments + } + }; let reward = (increments * base_reward_per_increment) .checked_mul(weight) .ok_or(Error::ArithmeticOverflow( @@ -1770,6 +1784,7 @@ pub fn process_operations( body: &electra::BeaconBlockBody, config: &Config, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, ) -> Result<()> { let deposit_requests_start_index = block_ref(state, "process_operations")?.deposit_requests_start_index(); @@ -1795,7 +1810,7 @@ pub fn process_operations( } // [Modified in Electra:EIP7549] for attestation in body.attestations.iter() { - process_attestation(state, attestation, committees)?; + process_attestation(state, attestation, committees, active_balances)?; } for deposit in body.deposits.iter() { process_deposit(state, deposit, config)?; @@ -1836,6 +1851,7 @@ pub fn process_block( config: &Config, engine: &ExecutionEngine, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, options: super::block::BlockProcessingOptions, ) -> Result<()> { super::block::process_block_header( @@ -1849,8 +1865,8 @@ pub fn process_block( process_execution_payload(state, &block.body, config, engine)?; super::block::process_randao(state, &block.body.randao_reveal, options.randao)?; super::block::process_eth1_data(state, &block.body.eth1_data)?; - process_operations(state, &block.body, config, committees)?; - super::altair::process_sync_aggregate(state, &block.body.sync_aggregate)?; + process_operations(state, &block.body, config, committees, active_balances)?; + super::altair::process_sync_aggregate(state, &block.body.sync_aggregate, active_balances)?; Ok(()) } diff --git a/crates/blockchain/state_transition/src/beacon/stf/fulu.rs b/crates/blockchain/state_transition/src/beacon/stf/fulu.rs index f2618905e..1a8bc3b58 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/fulu.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/fulu.rs @@ -43,7 +43,9 @@ use crate::beacon::config::Config; use crate::beacon::containers::{BeaconState, deneb, electra}; use crate::beacon::error::{Result, verify}; -use crate::beacon::helpers::accessors::{CommitteeCache, get_current_epoch, get_randao_mix}; +use crate::beacon::helpers::accessors::{ + ActiveBalanceCache, CommitteeCache, get_current_epoch, get_randao_mix, +}; use crate::beacon::helpers::electra::pending_queue_fields; use crate::beacon::helpers::fulu::{fulu_state, fulu_state_ref}; use crate::beacon::primitives::{Bytes32, HashTreeRoot as _}; @@ -65,6 +67,7 @@ pub fn process_block( config: &Config, engine: &ExecutionEngine, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, options: super::block::BlockProcessingOptions, ) -> Result<()> { super::block::process_block_header( @@ -78,8 +81,8 @@ pub fn process_block( process_execution_payload(state, &block.body, config, engine)?; super::block::process_randao(state, &block.body.randao_reveal, options.randao)?; super::block::process_eth1_data(state, &block.body.eth1_data)?; - process_operations(state, &block.body, config, committees)?; - super::altair::process_sync_aggregate(state, &block.body.sync_aggregate)?; + process_operations(state, &block.body, config, committees, active_balances)?; + super::altair::process_sync_aggregate(state, &block.body.sync_aggregate, active_balances)?; Ok(()) } @@ -220,6 +223,7 @@ pub fn process_operations( body: &electra::BeaconBlockBody, config: &Config, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, ) -> Result<()> { // [Modified in Fulu] verify(body.deposits.is_empty(), "len(body.deposits) == 0")?; @@ -231,7 +235,7 @@ pub fn process_operations( super::electra::process_attester_slashing(state, attester_slashing, config)?; } for attestation in body.attestations.iter() { - super::electra::process_attestation(state, attestation, committees)?; + super::electra::process_attestation(state, attestation, committees, active_balances)?; } // [Modified in Fulu] // Removed `process_deposit` diff --git a/crates/blockchain/state_transition/src/beacon/stf/gloas.rs b/crates/blockchain/state_transition/src/beacon/stf/gloas.rs index 2ae164fc2..0ef4c1c8c 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/gloas.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/gloas.rs @@ -54,10 +54,11 @@ use crate::beacon::containers::shared::{DepositMessage, ProposerSlashing}; use crate::beacon::containers::{BeaconState, capella, gloas}; use crate::beacon::error::{Error, Result, verify}; use crate::beacon::helpers::accessors::{ - CommitteeCache, CommitteeCacheExt, get_beacon_proposer_index, get_block_root_at_slot, - get_current_epoch, get_domain, get_previous_epoch, get_randao_mix, + ActiveBalanceCache, ActiveBalanceCacheExt, CommitteeCache, CommitteeCacheExt, + get_beacon_proposer_index, get_block_root_at_slot, get_current_epoch, get_domain, + get_previous_epoch, get_randao_mix, }; -use crate::beacon::helpers::altair::{add_flag, get_base_reward_per_increment, has_flag}; +use crate::beacon::helpers::altair::{add_flag, base_reward_per_increment, has_flag}; use crate::beacon::helpers::electra::{g2_point_at_infinity, get_committee_indices}; use crate::beacon::helpers::gloas::{ add_builder_to_registry, can_builder_cover_bid, convert_builder_index_to_validator_index, @@ -102,6 +103,7 @@ pub fn process_block( block: &gloas::BeaconBlock, config: &Config, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, options: super::block::BlockProcessingOptions, ) -> Result<()> { // [New in Gloas:EIP7732] @@ -124,8 +126,15 @@ pub fn process_block( super::block::process_randao(state, &block.body.randao_reveal, options.randao)?; super::block::process_eth1_data(state, &block.body.eth1_data)?; // [Modified in Gloas:EIP7732] - process_operations(state, &block.body, parent_slot, config, committees)?; - super::altair::process_sync_aggregate(state, &block.body.sync_aggregate)?; + process_operations( + state, + &block.body, + parent_slot, + config, + committees, + active_balances, + )?; + super::altair::process_sync_aggregate(state, &block.body.sync_aggregate, active_balances)?; Ok(()) } @@ -1293,6 +1302,7 @@ pub fn process_attestation( attestation: &gloas::Attestation, parent_slot: Slot, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, ) -> Result<()> { let data = attestation.data; let current_epoch = get_current_epoch(state); @@ -1395,7 +1405,15 @@ pub fn process_attestation( // constant across this whole read phase (nothing here mutates `state` // yet), so computing it once outside the loop below avoids one // `get_total_active_balance` scan of the registry per attester per flag. - let base_reward_per_increment = get_base_reward_per_increment(state)?; + // + // Served from the store's total-active-balance cache: no gloas block + // operation writes a validator's effective balance (the effective-balance + // update at an epoch's end is the only writer, and the builder and + // execution-request paths touch balances, exit epochs and queues only), so + // the total is fixed for the epoch. The debug cross-check on every cache + // hit enforces that. + let base_reward_per_increment = + base_reward_per_increment(active_balances.total_active_balance(state)?); // Read phase: for every attester, decide which flags this attestation // newly satisfies, add up the proposer's reward for granting them, and @@ -1419,6 +1437,10 @@ pub fn process_attestation( )?; let had_no_participation = current_flags == 0; + // The attester's validator is read at most once, on the first + // flag it newly earns: each read is a tree descent, and the + // effective balance cannot change inside this read-only phase. + let mut attester_effective_balance: Option = None; let mut new_flags: ParticipationFlags = 0; for &flag_index in &participation_flag_indices { if has_flag(current_flags, flag_index) { @@ -1426,8 +1448,15 @@ pub fn process_attestation( } new_flags = add_flag(new_flags, flag_index); let weight = constants::PARTICIPATION_FLAG_WEIGHTS[flag_index]; - let increments = - state.validator(index)?.effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + let effective_balance = match attester_effective_balance { + Some(effective_balance) => effective_balance, + None => { + let effective_balance = state.validator(index)?.effective_balance; + attester_effective_balance = Some(effective_balance); + effective_balance + } + }; + let increments = effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT; let base_reward = increments.checked_mul(base_reward_per_increment).ok_or( Error::ArithmeticOverflow("process_attestation: get_base_reward(state, index)"), )?; @@ -1447,7 +1476,11 @@ pub fn process_attestation( && is_same_slot && payment.withdrawal.amount > 0 { - let effective_balance = state.validator(index)?.effective_balance; + // `will_set_new_flag` means the loop above read it already. + let effective_balance = match attester_effective_balance { + Some(effective_balance) => effective_balance, + None => state.validator(index)?.effective_balance, + }; payment.weight = payment.weight.checked_add(effective_balance).ok_or( Error::ArithmeticOverflow( "process_attestation: payment.weight + validator.effective_balance", @@ -1554,6 +1587,7 @@ pub fn process_operations( parent_slot: Slot, config: &Config, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, ) -> Result<()> { verify( body.deposits.is_empty(), @@ -1595,7 +1629,7 @@ pub fn process_operations( } // [Modified in Gloas:EIP7732] for attestation in body.attestations.iter() { - process_attestation(state, attestation, parent_slot, committees)?; + process_attestation(state, attestation, parent_slot, committees, active_balances)?; } for voluntary_exit in body.voluntary_exits.iter() { crate::beacon::stf::electra::process_voluntary_exit(state, voluntary_exit, config)?; @@ -1888,7 +1922,14 @@ mod tests { for (expected_message, body) in cases { let mut state = state.clone(); let committees = CommitteeCache::default(); - let result = process_operations(&mut state, &body, 0, &config, &committees); + let result = process_operations( + &mut state, + &body, + 0, + &config, + &committees, + &ActiveBalanceCache::default(), + ); assert!( matches!(result, Err(Error::SpecAssert(message)) if message == expected_message), "expected {expected_message:?} to fail, got {result:?}" diff --git a/crates/blockchain/state_transition/src/beacon/stf/mod.rs b/crates/blockchain/state_transition/src/beacon/stf/mod.rs index 6687d6f59..d5b9dfbf0 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/mod.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/mod.rs @@ -67,7 +67,7 @@ pub mod operations; use crate::beacon::containers; use crate::beacon::containers::{BeaconState, phase0}; use crate::beacon::error::{Error, Result, verify}; -use crate::beacon::helpers::accessors::{CommitteeCache, get_domain}; +use crate::beacon::helpers::accessors::{ActiveBalanceCache, CommitteeCache, get_domain}; use crate::beacon::helpers::misc::compute_signing_root; use crate::beacon::lean_state_unreachable; use crate::beacon::preset; @@ -134,6 +134,7 @@ pub fn state_transition( config: &Config, engine: &ExecutionEngine, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, ) -> Result<()> { process_slots(state, signed_block.slot(), config)?; @@ -161,6 +162,7 @@ pub fn state_transition( config, engine, committees, + active_balances, block::BlockProcessingOptions::default(), )?; // As in `process_slot`: the post-state's root, checked below and needed diff --git a/crates/blockchain/state_transition/src/beacon/stf/operations.rs b/crates/blockchain/state_transition/src/beacon/stf/operations.rs index 10f2d8689..5f9add7cc 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/operations.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/operations.rs @@ -23,8 +23,8 @@ use crate::beacon::containers::shared::{ use crate::beacon::error::{Error, Result, verify}; use crate::beacon::fork::ForkName; use crate::beacon::helpers::accessors::{ - CommitteeCache, CommitteeCacheExt, get_beacon_proposer_index, get_current_epoch, get_domain, - get_previous_epoch, + ActiveBalanceCache, CommitteeCache, CommitteeCacheExt, get_beacon_proposer_index, + get_current_epoch, get_domain, get_previous_epoch, }; use crate::beacon::helpers::attestation::{get_indexed_attestation, is_valid_indexed_attestation}; use crate::beacon::helpers::misc::{ @@ -73,6 +73,7 @@ pub fn process_operations( voluntary_exits: &[SignedVoluntaryExit], config: &Config, committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, ) -> Result<()> { // Checked before anything below can mutate `state`, and unconditionally // rather than only when `attestations` is non-empty: a refused fork must @@ -133,7 +134,12 @@ pub fn process_operations( // `get_base_reward` implementations: neither is renamed, and the call // site picks between them by fully-qualified path. if altair_attestations { - crate::beacon::stf::altair::process_attestation(state, attestation, committees)?; + crate::beacon::stf::altair::process_attestation( + state, + attestation, + committees, + active_balances, + )?; } else { process_attestation(state, attestation, config, committees)?; } diff --git a/crates/blockchain/state_transition/src/metrics.rs b/crates/blockchain/state_transition/src/metrics.rs index e5ffb635b..c9e1b8c7e 100644 --- a/crates/blockchain/state_transition/src/metrics.rs +++ b/crates/blockchain/state_transition/src/metrics.rs @@ -62,6 +62,24 @@ pub fn inc_committee_cache_lookups(result: &str) { .inc(); } +static LEAN_BEACON_TOTAL_ACTIVE_BALANCE_LOOKUPS_TOTAL: LazyLock = + LazyLock::new(|| { + register_int_counter_vec!( + "lean_beacon_total_active_balance_lookups_total", + "Total active balance lookups, by whether the active balance cache served them", + &["result"] + ) + .unwrap() + }); + +/// Count one `ActiveBalanceCache` lookup: `hit`, `miss` (a registry pass was +/// run and cached), or `unkeyable` (computed for one caller, not cached). +pub fn inc_total_active_balance_lookups(result: &str) { + LEAN_BEACON_TOTAL_ACTIVE_BALANCE_LOOKUPS_TOTAL + .with_label_values(&[result]) + .inc(); +} + static LEAN_STATE_TRANSITION_TIME_SECONDS: LazyLock = LazyLock::new(|| { register_histogram!( "lean_state_transition_time_seconds", diff --git a/crates/blockchain/state_transition/tests/beacon_spec/operations.rs b/crates/blockchain/state_transition/tests/beacon_spec/operations.rs index 927d0a4ff..e627daeba 100644 --- a/crates/blockchain/state_transition/tests/beacon_spec/operations.rs +++ b/crates/blockchain/state_transition/tests/beacon_spec/operations.rs @@ -131,7 +131,7 @@ use ethlambda_state_transition::beacon::config::Config; use ethlambda_state_transition::beacon::containers::{ BeaconState, altair, bellatrix, capella, deneb, electra, gloas, phase0, shared, }; -use ethlambda_state_transition::beacon::helpers::accessors::CommitteeCache; +use ethlambda_state_transition::beacon::helpers::accessors::{ActiveBalanceCache, CommitteeCache}; use ethlambda_state_transition::beacon::primitives::{HashTreeRoot as _, Slot}; use ethlambda_state_transition::beacon::stf::altair as altair_stf; use ethlambda_state_transition::beacon::stf::bellatrix as bellatrix_stf; @@ -177,6 +177,7 @@ fn apply( // calls fits on one line; a case processes a single attestation, so there // is nothing for the cache to share between calls. let committees = CommitteeCache::default(); + let active_balances = ActiveBalanceCache::default(); let outcome = match handler { // Every fork's own `BeaconBlock` carries a different concrete body // (altair's adds a sync aggregate, bellatrix's an execution payload, @@ -280,20 +281,20 @@ fn apply( // so altair's own function serves both. ForkName::Altair | ForkName::Bellatrix | ForkName::Capella => { let attestation: phase0::Attestation = case.ssz("attestation"); - altair_stf::process_attestation(state, &attestation, &committees) + altair_stf::process_attestation(state, &attestation, &committees, &active_balances) } // Deneb widens the inclusion window and the timely-target // condition (EIP-7045); the container is still phase0's. ForkName::Deneb => { let attestation: phase0::Attestation = case.ssz("attestation"); - deneb_stf::process_attestation(state, &attestation, &committees) + deneb_stf::process_attestation(state, &attestation, &committees, &active_balances) } // Electra reshapes the container itself (EIP-7549's // `committee_bits`), and fulu's specification makes no further // change to either the container or the function. ForkName::Electra | ForkName::Fulu => { let attestation: electra::Attestation = case.ssz("attestation"); - electra_stf::process_attestation(state, &attestation, &committees) + electra_stf::process_attestation(state, &attestation, &committees, &active_balances) } // Gloas reshapes the container a third time (EIP-7688's unbounded // `AttestingIndices`) and modifies the function again (the new @@ -304,7 +305,13 @@ fn apply( ForkName::Gloas => { let attestation: gloas::Attestation = case.ssz("attestation"); let meta: AttestationMeta = case.yaml("meta"); - gloas_stf::process_attestation(state, &attestation, meta.parent_slot, &committees) + gloas_stf::process_attestation( + state, + &attestation, + meta.parent_slot, + &committees, + &active_balances, + ) } ForkName::Lean => lean_is_not_a_fixture_fork("attestation"), }, @@ -423,7 +430,7 @@ fn apply( // further fork match. "sync_aggregate" => { let sync_aggregate: altair::SyncAggregate = case.ssz("sync_aggregate"); - altair_stf::process_sync_aggregate(state, &sync_aggregate) + altair_stf::process_sync_aggregate(state, &sync_aggregate, &active_balances) } // The fixture's operation file is a whole `BeaconBlockBody`, not a // bare payload: checking one needs fields that live on the body diff --git a/crates/blockchain/state_transition/tests/beacon_spec/sanity.rs b/crates/blockchain/state_transition/tests/beacon_spec/sanity.rs index 224a9f5ef..7d85ee935 100644 --- a/crates/blockchain/state_transition/tests/beacon_spec/sanity.rs +++ b/crates/blockchain/state_transition/tests/beacon_spec/sanity.rs @@ -15,7 +15,7 @@ use ethlambda_state_transition::beacon::config::Config; use ethlambda_state_transition::beacon::containers::{BeaconState, SignedBeaconBlock}; -use ethlambda_state_transition::beacon::helpers::accessors::CommitteeCache; +use ethlambda_state_transition::beacon::helpers::accessors::{ActiveBalanceCache, CommitteeCache}; use ethlambda_state_transition::beacon::stf::{self, ExecutionEngine}; use libtest_mimic::Trial; @@ -48,6 +48,7 @@ fn apply_blocks(case: &Case, state: &mut BeaconState, config: &Config) -> Result // One cache across the case's blocks, as the node holds one across its // imports, so consecutive blocks of an epoch share its shuffling. let committees = CommitteeCache::default(); + let active_balances = ActiveBalanceCache::default(); for index in 0..meta.blocks_count { let bytes = case.ssz_bytes_indexed("blocks", index); @@ -58,8 +59,16 @@ fn apply_blocks(case: &Case, state: &mut BeaconState, config: &Config) -> Result state.latest_block_header_mut().state_root = root; } - stf::state_transition(state, &block, true, config, &engine, &committees) - .map_err(|err| format!("block {index} rejected: {err:?}"))?; + stf::state_transition( + state, + &block, + true, + config, + &engine, + &committees, + &active_balances, + ) + .map_err(|err| format!("block {index} rejected: {err:?}"))?; previous_state_root = Some(block.state_root()); } diff --git a/crates/blockchain/state_transition/tests/beacon_spec/transition.rs b/crates/blockchain/state_transition/tests/beacon_spec/transition.rs index 7e4dd5627..dc6d5a2f1 100644 --- a/crates/blockchain/state_transition/tests/beacon_spec/transition.rs +++ b/crates/blockchain/state_transition/tests/beacon_spec/transition.rs @@ -69,7 +69,7 @@ use std::sync::Arc; use ethlambda_state_transition::beacon::ForkName; use ethlambda_state_transition::beacon::config::Config; use ethlambda_state_transition::beacon::containers::{BeaconState, SignedBeaconBlock}; -use ethlambda_state_transition::beacon::helpers::accessors::CommitteeCache; +use ethlambda_state_transition::beacon::helpers::accessors::{ActiveBalanceCache, CommitteeCache}; use ethlambda_state_transition::beacon::helpers::misc::compute_start_slot_at_epoch; use ethlambda_state_transition::beacon::primitives::Epoch; use ethlambda_state_transition::beacon::stf::{self, ExecutionEngine}; @@ -138,6 +138,7 @@ fn apply_blocks( // One cache across the case's blocks, as the node holds one across its // imports, so consecutive blocks of an epoch share its shuffling. let committees = CommitteeCache::default(); + let active_balances = ActiveBalanceCache::default(); for index in 0..meta.blocks_count { let fork = if index < first_post_fork_index { @@ -154,8 +155,16 @@ fn apply_blocks( state.latest_block_header_mut().state_root = root; } - stf::state_transition(state, &block, true, &config, &engine, &committees) - .map_err(|err| format!("block {index} rejected: {err:?}"))?; + stf::state_transition( + state, + &block, + true, + &config, + &engine, + &committees, + &active_balances, + ) + .map_err(|err| format!("block {index} rejected: {err:?}"))?; previous_state_root = Some(block.state_root()); } diff --git a/crates/net/rpc/src/beacon/validator_client_tests.rs b/crates/net/rpc/src/beacon/validator_client_tests.rs index bdb1e82f3..770a21e70 100644 --- a/crates/net/rpc/src/beacon/validator_client_tests.rs +++ b/crates/net/rpc/src/beacon/validator_client_tests.rs @@ -994,8 +994,12 @@ fn assert_full_sync_aggregate( "every member signed the parent" ); let mut state = advanced.clone(); - ethlambda_state_transition::beacon::stf::altair::process_sync_aggregate(&mut state, aggregate) - .expect("the packed aggregate verifies as the state transition checks it"); + ethlambda_state_transition::beacon::stf::altair::process_sync_aggregate( + &mut state, + aggregate, + ðlambda_state_transition::beacon::helpers::accessors::ActiveBalanceCache::default(), + ) + .expect("the packed aggregate verifies as the state transition checks it"); } /// A sync committee member's slot through this node: the duties and diff --git a/crates/storage/src/active_balance_cache.rs b/crates/storage/src/active_balance_cache.rs new file mode 100644 index 000000000..fecea5ee3 --- /dev/null +++ b/crates/storage/src/active_balance_cache.rs @@ -0,0 +1,171 @@ +//! The total-active-balance cache: one [`Gwei`] per epoch, shared across every +//! caller asking the same epoch's total of a state that agrees on the block +//! that fixes it. +//! +//! A sibling of [`crate::committee_cache::CommitteeCache`], held by the +//! `Store` for the same reasons (see that module's documentation): one table +//! shared by the chain actor's state transition and fork choice, and by any +//! other task that holds the store. A caller with no `Store` of its own (a +//! spec runner, a one-off lookup, a unit test, block production's scratch +//! state) holds a fresh [`ActiveBalanceCache::default`] for as long as that +//! work lasts instead. +//! +//! This module knows nothing about a `BeaconState`: it is handed an +//! [`ActiveBalanceKey`] and, on a miss, runs a closure that returns the total. +//! The consensus logic that derives the key from a state, and the one-pass +//! sum that computes the value, lives in `ethlambda-state-transition`'s +//! `beacon::helpers::accessors`, in the `ActiveBalanceCacheExt` trait this +//! type implements there; that crate depends on this one, not the other way +//! around. +//! +//! Unlike the committee cache there is no single-flight machinery: the value +//! is one registry pass, cheap next to a whole-epoch shuffle, so two callers +//! racing on the same missing key may both compute it. They compute the same +//! number (the key fixes it), so the second insert is a no-op. + +use std::sync::Mutex; + +use ethlambda_types::beacon::primitives::{Epoch, Gwei, Root}; + +pub use crate::committee_cache::Lookup; + +/// What pins the total active balance a state names for an epoch: the epoch +/// itself, and the last block root that could still have changed it. +/// +/// Opaque to this module: `epoch` and `decision_root` are read only for +/// equality and, for eviction, ordering by `epoch`. +/// `ethlambda-state-transition`'s `beacon::helpers::accessors::active_balance_key` +/// derives one from a state and documents why that root is sound; note that +/// it is the last slot of `epoch - 1`, one epoch later than a +/// [`crate::ShufflingKey`]'s, so the two key types are kept apart rather than +/// shared. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ActiveBalanceKey { + pub epoch: Epoch, + pub decision_root: Root, +} + +/// How many distinct totals stay resident in an [`ActiveBalanceCache`]. +/// +/// A block asks for one epoch's total; fork choice and sibling branches ask +/// for a few more. Entries are 8 bytes of value plus a 40 byte key, so room +/// for many concurrent forks and several epochs each costs next to nothing, +/// and the bound is only there so a long-lived store cannot grow the table +/// without limit. +const ACTIVE_BALANCE_CACHE_CAPACITY: usize = 32; + +/// Totals shared across the calls asking for the same epoch's total active +/// balance, so a block derives it once instead of once per attestation. +/// +/// Internally synchronized, so every method takes `&self`. The lock is held +/// only for the table lookup or insert, never while the value is computed. +/// +/// # Eviction +/// +/// A miss on a full table drops the entry for the lowest epoch (the earliest +/// inserted among equals): an older epoch's total is less likely to be asked +/// for again than a newer one's, whichever branch either belongs to. Every +/// lookup is counted in `lean_beacon_total_active_balance_lookups_total`. +#[derive(Debug, Default)] +pub struct ActiveBalanceCache { + entries: Mutex>, +} + +impl ActiveBalanceCache { + /// `key`'s total, running `compute` on a miss and remembering the result. + /// + /// `compute` runs outside the lock. Concurrent misses on one key may each + /// run it; see the module documentation for why that is acceptable. + pub fn get_or_compute( + &self, + key: ActiveBalanceKey, + compute: impl FnOnce() -> Gwei, + ) -> (Gwei, Lookup) { + if let Some(total) = self.get(key) { + return (total, Lookup::Hit); + } + let total = compute(); + let mut entries = self.entries.lock().unwrap(); + if !entries.iter().any(|(entry_key, _)| *entry_key == key) { + if entries.len() >= ACTIVE_BALANCE_CACHE_CAPACITY { + let victim = entries + .iter() + .enumerate() + .min_by_key(|(_, (entry_key, _))| entry_key.epoch) + .map(|(position, _)| position); + if let Some(position) = victim { + entries.remove(position); + } + } + entries.push((key, total)); + } + (total, Lookup::Miss) + } + + /// The resident total for `key`, if any. + pub fn get(&self, key: ActiveBalanceKey) -> Option { + self.entries + .lock() + .unwrap() + .iter() + .find(|(entry_key, _)| *entry_key == key) + .map(|(_, total)| *total) + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use super::*; + + fn key(epoch: Epoch, decision_root: u8) -> ActiveBalanceKey { + ActiveBalanceKey { + epoch, + decision_root: Root::repeat_byte(decision_root), + } + } + + #[test] + fn a_repeat_lookup_is_served_from_the_cache() { + let cache = ActiveBalanceCache::default(); + let (first, first_lookup) = cache.get_or_compute(key(1, 0), || 7); + let (second, second_lookup) = cache.get_or_compute(key(1, 0), || panic!("rebuilt")); + assert_eq!((first, first_lookup), (7, Lookup::Miss)); + assert_eq!((second, second_lookup), (7, Lookup::Hit)); + } + + #[test] + fn keys_with_different_roots_or_epochs_are_distinct_entries() { + let cache = ActiveBalanceCache::default(); + cache.get_or_compute(key(1, 1), || 10); + let computed = Cell::new(0); + for (k, value) in [(key(1, 2), 20), (key(2, 1), 30)] { + let (total, lookup) = cache.get_or_compute(k, || { + computed.set(computed.get() + 1); + value + }); + assert_eq!((total, lookup), (value, Lookup::Miss)); + } + assert_eq!(computed.get(), 2); + assert_eq!(cache.get(key(1, 1)), Some(10)); + } + + #[test] + fn a_miss_on_a_full_cache_evicts_the_lowest_epoch() { + let cache = ActiveBalanceCache::default(); + // Newest first, so eviction by epoch and by insertion order disagree. + for epoch in (0..ACTIVE_BALANCE_CACHE_CAPACITY as Epoch).rev() { + cache.get_or_compute(key(epoch, 0), || epoch); + } + cache.get_or_compute(key(1000, 0), || 1000); + + assert_eq!(cache.get(key(0, 0)), None); + assert_eq!(cache.get(key(1, 0)), Some(1)); + assert_eq!(cache.get(key(1000, 0)), Some(1000)); + assert_eq!( + cache.entries.lock().unwrap().len(), + ACTIVE_BALANCE_CACHE_CAPACITY + ); + } +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 22a61143c..85ad30c5a 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -1,3 +1,4 @@ +mod active_balance_cache; mod api; pub mod backend; mod beacon_state_delta; @@ -11,6 +12,7 @@ mod state_diff; mod state_writer; mod store; +pub use active_balance_cache::{ActiveBalanceCache, ActiveBalanceKey}; pub use api::{ ALL_TABLES, StorageBackend, StorageReadView, StorageReadViewExt, StorageWriteBatch, Table, }; diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index c8e09c839..91a4c8571 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -5,6 +5,7 @@ use std::time::Instant; use lru::LruCache; +use crate::active_balance_cache::ActiveBalanceCache; use crate::api::{StorageBackend, StorageReadView, StorageReadViewExt, StorageWriteBatch, Table}; use crate::committee_cache::CommitteeCache; use crate::error::Error; @@ -1055,6 +1056,13 @@ pub struct Store { /// node's own API), which is why it lives on the `Store` all three share. /// See [`ObservedLiveness`]. Always empty on lean. observed_liveness: Arc, + /// The total active balance per epoch, shared the way + /// [`Self::committee_cache`] is: by the chain actor's state transition + /// and anything else holding a `Store` clone. See + /// [`ActiveBalanceCache`], and `ethlambda-state-transition`'s + /// `beacon::helpers::accessors::ActiveBalanceCacheExt` for the + /// state-aware half. Always empty on lean. + active_balance_cache: Arc, /// Beacon fork-choice scratch. Empty and untouched on a lean chain. pub(crate) beacon: Arc>, /// The background writer, joined when the last clone of this `Store` @@ -1696,6 +1704,7 @@ impl Store { pending_states, committee_cache: Arc::new(CommitteeCache::default()), observed_liveness: Arc::new(ObservedLiveness::default()), + active_balance_cache: Arc::new(ActiveBalanceCache::default()), beacon: Default::default(), state_writer, state_handoff: Default::default(), @@ -2991,6 +3000,13 @@ impl Store { &self.observed_liveness } + /// The total-active-balance cache shared by every clone of this store. + /// + /// An owned `Arc` for the same reason as [`Self::committee_cache`]. + pub fn active_balance_cache(&self) -> Arc { + Arc::clone(&self.active_balance_cache) + } + /// Returns whether a state is available for the given block root. /// /// True if `pending_states` or the state cache holds the state, a snapshot @@ -6781,6 +6797,24 @@ mod tests { assert!(Arc::ptr_eq(&first, &second)); } + #[test] + fn the_active_balance_cache_is_shared_across_store_clones() { + let store = beacon_test_store(Arc::new(InMemoryBackend::new())); + let clone = store.clone(); + let key = crate::ActiveBalanceKey { + epoch: 1, + decision_root: H256::from([1u8; 32]), + }; + + let (first, first_lookup) = clone.active_balance_cache().get_or_compute(key, || 42); + let (second, second_lookup) = store + .active_balance_cache() + .get_or_compute(key, || panic!("should not recompute")); + + assert_eq!((first, first_lookup), (42, Lookup::Miss)); + assert_eq!((second, second_lookup), (42, Lookup::Hit)); + } + #[test] fn the_state_cache_is_bounded() { let store = beacon_test_store(Arc::new(InMemoryBackend::new())); diff --git a/docs/beacon_stf.md b/docs/beacon_stf.md index 313da7e70..a4fe15c32 100644 --- a/docs/beacon_stf.md +++ b/docs/beacon_stf.md @@ -293,15 +293,29 @@ below): persistent Merkle trees in the shape of the SSZ one, modeled on the The access pattern matters. `state.validator(i)` and `state.balance(i)` are tree descents, cheap next to a hash but far from an array index, and they add up when a helper calls them once per validator: -`get_total_active_balance` builds the active-index `Vec` and then reads every -index back, and runs several times per block (once per attestation through -`get_base_reward_per_increment`, once per execution request through the churn -limits). In the 2026-09-28 import profile, those per-index reads and the -repeated whole-registry scans were the largest cost left after hashing. A loop -over the registry should walk `iter_validators()`, zipped with -`iter_balances()` where it needs both. The total active balance is the obvious -candidate for computing once per epoch rather than per call, once it is shown -that no block operation changes it mid-epoch. +`get_total_active_balance` used to build the active-index `Vec` and then read +every index back, and runs several times per block (once per attestation through +`get_base_reward_per_increment`, twice per sync aggregate, once per execution +request through the churn limits). In the 2026-09-28 import profile, those +per-index reads and the repeated whole-registry scans were the largest cost left +after hashing. A loop over the registry should walk `iter_validators()`, +zipped with `iter_balances()` where it needs both; `get_total_active_balance` +now does, in one pass. + +Block processing does not even pay that pass more than once per epoch. The +total is cached in `ActiveBalanceCache` (`crates/storage/src/active_balance_cache.rs`), +held by the `Store` beside the committee cache and consulted through +`ActiveBalanceCacheExt::total_active_balance`. The key is the epoch plus the +block root at the last slot of the epoch before it: effective balances are +written only by that epoch's `process_effective_balance_updates`, and every +activation or exit lands at least `MAX_SEED_LOOKAHEAD` epochs ahead, so no block +inside the epoch can move the total, and two states agreeing on that root agree +on it. The cache lives outside the state, so it adds no field to `BeaconState` +and no SSZ concern; callers with no store (spec runners, block production, +tests) hold a fresh `ActiveBalanceCache::default()`. Epoch processing, churn +helpers and fork choice keep calling the uncached one-pass +`get_total_active_balance`. In debug builds every hit is cross-checked against a +fresh computation. Epoch steps 1-3 (justification, inactivity updates, rewards) follow that rule through `helpers::participation`. One walk of `iter_validators()`, zipped with diff --git a/docs/metrics.md b/docs/metrics.md index d6d7d1f21..87ca59168 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -467,6 +467,22 @@ is a registry scan plus a whole-epoch shuffle on the import thread. `unkeyable` is a lookup the cache could not key at all, mostly the genesis state asking about its own first epochs, and should be zero on a checkpoint-synced follower. +### Beacon Total Active Balance Cache + +The total active balance (the divisor of every attestation's base reward and of +the sync aggregate's rewards) is one registry pass, and an ordinary block asks +for it about nine times. `ActiveBalanceCache` in the `Store` keeps it per epoch, +keyed by the epoch and the block root that fixes it. This is ethlambda-specific, +not part of the leanMetrics spec. + +| Name | Type | Usage | Sample collection event | Labels | +|------|------|-------|-------------------------|--------| +| `lean_beacon_total_active_balance_lookups_total` | Counter | Total active balance lookups, by whether the cache served them | On every `ActiveBalanceCacheExt::total_active_balance` call: block processing's attestations and sync aggregate | result=hit,miss,unkeyable | + +Expect one miss per epoch per chain, and hits otherwise. `unkeyable` is the +genesis state asking about its own epoch, and should be zero on a +checkpoint-synced follower. + ### Beacon Pubkey Cache Every BLS signature check `ethlambda beacon` runs (block import, fork choice,