From 1490e27a25feec7c82a3cb79104b7497e759c0c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:44:18 -0300 Subject: [PATCH 1/2] perf(beacon): keep unslashed target balances incrementally for the pulled-up tip compute_pulled_up_tip ran on every imported block and, at ~2.4M validators, cloned the whole post-state (~2.5 ms) and scanned the registry for the participation totals (~6 ms), plus an uncached total active balance. Lighthouse avoids both with its ProgressiveBalancesCache. The per-fork states derive SSZ, so the cache lives in the Store, keyed by block root (TargetBalanceCache: raw previous/current unslashed TIMELY_TARGET sums, bounded by capacity and pruned below the finalized epoch). process_attestation in every fork from altair on records into a TargetBalanceDelta, carried in BlockProcessingOptions, when a validator newly earns the target flag. A block in the same epoch as its parent has the parent's totals plus that delta; an epoch boundary, a cache miss or a block carrying slashings takes one registry scan, cached for the children. The justification weighing now runs on a small JustificationState extracted from the state, so the pulled-up tip clones nothing and reads the total active balance from the store's ActiveBalanceCache. weigh_justification_and_finalization delegates to the same logic, so epoch processing is unchanged. Debug builds also run the old full path and assert the checkpoints and totals agree, which the fork_choice fixtures exercise on every block. --- .../src/beacon/block_production.rs | 3 +- .../src/beacon/fork_choice.rs | 157 ++++++++++-- .../src/beacon/gloas_block_production.rs | 1 + .../src/beacon/helpers/mod.rs | 1 + .../src/beacon/helpers/participation.rs | 51 +++- .../src/beacon/helpers/target_balances.rs | 236 ++++++++++++++++++ .../state_transition/src/beacon/stf/altair.rs | 106 ++++++++ .../src/beacon/stf/bellatrix.rs | 1 + .../state_transition/src/beacon/stf/block.rs | 12 +- .../src/beacon/stf/capella.rs | 4 + .../state_transition/src/beacon/stf/deneb.rs | 17 +- .../src/beacon/stf/electra.rs | 25 +- .../src/beacon/stf/epoch/justification.rs | 215 ++++++++++------ .../state_transition/src/beacon/stf/fulu.rs | 19 +- .../state_transition/src/beacon/stf/gloas.rs | 19 +- .../state_transition/src/beacon/stf/mod.rs | 54 +++- .../src/beacon/stf/operations.rs | 3 + .../tests/beacon_spec/operations.rs | 25 +- crates/net/rpc/src/beacon/gloas_proposal.rs | 7 +- crates/net/rpc/src/beacon/proposal.rs | 7 +- crates/storage/src/lib.rs | 2 + crates/storage/src/store.rs | 17 ++ crates/storage/src/target_balance_cache.rs | 149 +++++++++++ 23 files changed, 1001 insertions(+), 130 deletions(-) create mode 100644 crates/blockchain/state_transition/src/beacon/helpers/target_balances.rs create mode 100644 crates/storage/src/target_balance_cache.rs diff --git a/crates/blockchain/state_transition/src/beacon/block_production.rs b/crates/blockchain/state_transition/src/beacon/block_production.rs index 9c3d0f8ce..33cc6dde0 100644 --- a/crates/blockchain/state_transition/src/beacon/block_production.rs +++ b/crates/blockchain/state_transition/src/beacon/block_production.rs @@ -708,9 +708,10 @@ mod tests { assert_eq!(block.state_root, post.hash_tree_root()); } - fn skip_randao() -> BlockProcessingOptions { + fn skip_randao() -> BlockProcessingOptions<'static> { BlockProcessingOptions { randao: RandaoVerification::Skip, + ..Default::default() } } diff --git a/crates/blockchain/state_transition/src/beacon/fork_choice.rs b/crates/blockchain/state_transition/src/beacon/fork_choice.rs index 02c959376..4121e8574 100644 --- a/crates/blockchain/state_transition/src/beacon/fork_choice.rs +++ b/crates/blockchain/state_transition/src/beacon/fork_choice.rs @@ -167,7 +167,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use ethlambda_storage::{CacheKey, ForkCheckpoints, StorageBackend}; +use ethlambda_storage::{CacheKey, ForkCheckpoints, StorageBackend, TargetBalances}; use ethlambda_types::ShortRoot; use tracing::{debug, error, warn}; @@ -179,8 +179,8 @@ use crate::beacon::containers::{bellatrix, deneb, electra, fulu, gloas, phase0}; use crate::beacon::error::{Error, Result, verify}; use crate::beacon::fork::ForkName; use crate::beacon::helpers::accessors::{ - ActiveBalanceCache, CommitteeCache, CommitteeCacheExt, get_active_validator_indices, - get_current_epoch, + ActiveBalanceCache, ActiveBalanceCacheExt, CommitteeCache, CommitteeCacheExt, + get_active_validator_indices, get_block_root, get_current_epoch, }; use crate::beacon::helpers::attestation as phase0_attestation; use crate::beacon::helpers::electra as electra_helpers; @@ -189,6 +189,10 @@ use crate::beacon::helpers::misc::{ compute_epoch_at_slot, compute_start_slot_at_epoch, is_valid_merkle_branch, }; use crate::beacon::helpers::predicates::{is_active_validator, is_slashable_attestation_data}; +use crate::beacon::helpers::target_balances::{ + TargetBalanceDelta, block_has_slashings, compute_target_balances, extend_parent_totals, + tracks_epoch, +}; use crate::beacon::kzg; use crate::beacon::lean_boundary::{lean_block_unreachable, lean_fork_unreachable}; use crate::beacon::preset; @@ -197,6 +201,7 @@ use crate::beacon::primitives::{ ValidatorIndex, }; use crate::beacon::stf; +use crate::beacon::stf::epoch::justification::JustificationState; use crate::metrics; // --------------------------------------------------------------------------- @@ -1059,6 +1064,7 @@ fn transition_block( engine: &stf::ExecutionEngine, committees: &CommitteeCache, active_balances: &ActiveBalanceCache, + options: stf::block::BlockProcessingOptions, ) -> (BeaconState, Result<()>) { let block_slot = signed_block.slot(); let epoch = compute_epoch_at_slot(block_slot); @@ -1077,7 +1083,7 @@ fn transition_block( else { let mut state = parent_state.clone(); if !crosses_with_empty_slots { - let outcome = stf::state_transition( + let outcome = stf::state_transition_with_options( &mut state, signed_block, validate_result, @@ -1085,6 +1091,7 @@ fn transition_block( engine, committees, active_balances, + options, ); return (state, outcome); } @@ -1094,7 +1101,7 @@ fn transition_block( .inspect(|()| cache_boundary_balances(store, boundary, &state)) .and_then(|()| stf::process_slots(&mut state, block_slot, config)) .and_then(|()| { - stf::apply_block( + stf::apply_block_with_options( &mut state, signed_block, validate_result, @@ -1102,6 +1109,7 @@ fn transition_block( engine, committees, active_balances, + options, ) }); return (state, outcome); @@ -1119,7 +1127,7 @@ fn transition_block( Ok(()) } .and_then(|()| { - stf::apply_block( + stf::apply_block_with_options( &mut state, signed_block, validate_result, @@ -1127,6 +1135,7 @@ fn transition_block( engine, committees, active_balances, + options, ) }); (state, outcome) @@ -4284,31 +4293,25 @@ pub fn is_data_available_gloas_columns( /// already having the checkpoint its own chain is clearly heading towards, /// rather than being stuck with whatever its post-state's /// `current_justified_checkpoint` was at the moment it was imported. +/// +/// `extended_totals` is the block's unslashed target balances when the caller +/// derived them from its parent's cached totals (see +/// [`extend_parent_totals`]); `None` makes this scan the registry once and +/// cache the result. pub fn compute_pulled_up_tip( store: &mut Store, block_root: Root, block_slot: Slot, config: &Config, + extended_totals: Option, ) -> Result<()> { - // `get_state` hands back a shared `Arc`, so this clones out of it, - // matching the specification's own `.copy()`: the clone advances to the - // next epoch boundary as a throwaway, and the store's own cached entry - // for `block_root` must be left exactly as the block itself produced it. let state = store .get_state(&block_root) .expect("get") .ok_or(Error::SpecAssert("block_root in store.block_states"))?; - let mut state = (*state).clone(); - // Through the fork-dispatching wrapper rather than phase0's version - // directly. Altair rewrote this step to read participation flags instead of - // replaying stored attestations, so calling phase0's against an altair or - // later state fails outright, which is what made every `fork_choice` case - // that crosses an epoch boundary fail. - stf::epoch::process_justification_and_finalization(&mut state, config)?; - - let current_justified = state.current_justified_checkpoint(); - let finalized = state.finalized_checkpoint(); + let (current_justified, finalized) = + pulled_up_checkpoints(store, &state, block_root, config, extended_totals)?; store.set_unrealized_justification(block_root, block_slot, current_justified); update_unrealized_checkpoints(store, current_justified, finalized); @@ -4325,6 +4328,92 @@ pub fn compute_pulled_up_tip( Ok(()) } +/// The `(current_justified, finalized)` checkpoints `state` heads towards, the +/// specification's `process_justification_and_finalization` run on a copy. +/// +/// From altair on this clones nothing: the step reads and writes a handful of +/// fields, so it runs on a [`JustificationState`] extracted from `state` and +/// reads the total active balance from the store's cache. The registry is +/// scanned only when `extended_totals` could not supply the two target +/// balances. Phase0 keeps the specification's own shape, since it replays +/// stored pending attestations instead of reading flags. +/// +/// In debug builds every call also runs the full path and asserts it agrees, +/// totals included, so the fork choice fixtures check the incremental ones on +/// each block. +fn pulled_up_checkpoints( + store: &Store, + state: &BeaconState, + block_root: Root, + config: &Config, + extended_totals: Option, +) -> Result<(Checkpoint, Checkpoint)> { + if state.fork_name() == ForkName::Phase0 { + return full_pulled_up_checkpoints(state, config); + } + + let mut justification = JustificationState::from_state(state); + if tracks_epoch(justification.current_epoch) { + let totals = match extended_totals { + Some(totals) => totals, + None => compute_target_balances(state)?, + }; + #[cfg(debug_assertions)] + assert_eq!( + totals, + compute_target_balances(state)?, + "incremental target balances diverged from a full scan" + ); + store.target_balance_cache().insert(block_root, totals); + + // Floored exactly as `ParticipationTotals` floors its three totals; + // the cached total active balance is floored already. + let floor = |total: Gwei| total.max(preset::EFFECTIVE_BALANCE_INCREMENT); + let total_active_balance = store.active_balance_cache().total_active_balance(state)?; + justification.weigh( + total_active_balance, + floor(totals.previous), + floor(totals.current), + |epoch| get_block_root(state, epoch), + )?; + } + + let outcome = ( + justification.current_justified_checkpoint, + justification.finalized_checkpoint, + ); + #[cfg(debug_assertions)] + assert_eq!( + outcome, + full_pulled_up_checkpoints(state, config)?, + "state-free pulled-up tip diverged from the specification's copy" + ); + Ok(outcome) +} + +/// [`pulled_up_checkpoints`] the way the specification states it: advance a +/// copy of `state` through `process_justification_and_finalization`. +/// +/// `get_state` hands back a shared `Arc`, so the copy matches the +/// specification's own `.copy()`: the store's cached entry for the block must +/// be left exactly as the block itself produced it. Through the +/// fork-dispatching wrapper rather than phase0's version directly: altair +/// rewrote this step to read participation flags instead of replaying stored +/// attestations, so calling phase0's against an altair or later state fails +/// outright, which is what made every `fork_choice` case that crosses an epoch +/// boundary fail. +fn full_pulled_up_checkpoints( + state: &BeaconState, + config: &Config, +) -> Result<(Checkpoint, Checkpoint)> { + let mut state = state.clone(); + stf::epoch::process_justification_and_finalization(&mut state, config)?; + Ok(( + state.current_justified_checkpoint(), + state.finalized_checkpoint(), + )) +} + // --------------------------------------------------------------------------- // on_block helpers // --------------------------------------------------------------------------- @@ -4949,6 +5038,10 @@ pub fn on_block( // 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(); + // What the block's attestations add to the unslashed target balances; with + // the parent's cached totals it spares `compute_pulled_up_tip` a registry + // scan. See `helpers::target_balances`. + let target_delta = TargetBalanceDelta::default(); let (mut state, transition) = transition_block( store, &parent_state, @@ -4959,6 +5052,10 @@ pub fn on_block( &engine, committees, &active_balances, + stf::block::BlockProcessingOptions { + target_delta: Some(&target_delta), + ..Default::default() + }, ); // `optimistic-sync.md`: a block deemed `INVALIDATED` MUST NOT be included @@ -5003,6 +5100,20 @@ pub fn on_block( transition?; + // The block's target balances when its parent's are cached for this epoch. + // Read before `signed_block` moves into the store. A block that slashes + // goes to the full scan: see `helpers::target_balances` for why. + let extended_totals = if block_has_slashings(&signed_block) { + None + } else { + extend_parent_totals( + &store.target_balance_cache(), + &parent_root, + compute_epoch_at_slot(signed_block.slot()), + &target_delta, + ) + }; + // A block at its epoch's first slot is its own epoch-boundary block: its // post-state is the checkpoint state. Cached at import so the tick that // justifies it finds the balances built; see `justified_balances`. @@ -5144,7 +5255,7 @@ pub fn on_block( update_checkpoints(store, current_justified, finalized); // Eagerly compute unrealized justification and finality. - compute_pulled_up_tip(store, block_root, block_slot, config)?; + compute_pulled_up_tip(store, block_root, block_slot, config, extended_totals)?; Ok(()) } @@ -6306,6 +6417,7 @@ mod tests { &stf::ExecutionEngine::valid(), &CommitteeCache::default(), &ActiveBalanceCache::default(), + Default::default(), ); outcome.expect("applies"); post.apply_pending_mutations(); @@ -6860,7 +6972,7 @@ mod tests { post_state.latest_block_header_mut().slot = slot; store.insert_signed_block(root, block).unwrap(); store.insert_state(root, post_state).unwrap(); - compute_pulled_up_tip(store, root, slot, config).expect("pulled-up tip"); + compute_pulled_up_tip(store, root, slot, config, None).expect("pulled-up tip"); root } @@ -10382,6 +10494,7 @@ mod tests { &stf::ExecutionEngine::valid(), &CommitteeCache::default(), &ActiveBalanceCache::default(), + Default::default(), ); outcome.expect("the block applies"); state.apply_pending_mutations(); 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 b4621d542..e0ca11c3c 100644 --- a/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs +++ b/crates/blockchain/state_transition/src/beacon/gloas_block_production.rs @@ -1011,6 +1011,7 @@ mod gloas_block_production_tests { let state = state_to_build_on(); let skip = BlockProcessingOptions { randao: RandaoVerification::Skip, + ..Default::default() }; let produce_with = |options| { let requests = ExecutionRequests::default(); diff --git a/crates/blockchain/state_transition/src/beacon/helpers/mod.rs b/crates/blockchain/state_transition/src/beacon/helpers/mod.rs index 7c0ee5503..fc1ce3093 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/mod.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/mod.rs @@ -28,5 +28,6 @@ pub mod participation_reference; pub mod predicates; pub mod shuffling; pub mod sync_committee; +pub mod target_balances; #[cfg(any(test, feature = "test-utils"))] pub mod test_state; diff --git a/crates/blockchain/state_transition/src/beacon/helpers/participation.rs b/crates/blockchain/state_transition/src/beacon/helpers/participation.rs index 10266925f..cf18940b7 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/participation.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/participation.rs @@ -99,10 +99,46 @@ impl ParticipationTotals { pub fn compute(state: &BeaconState) -> Result { scan(state, true, |_, _, _| {}) } + + /// The unslashed timely-target balances of the previous and current epoch + /// as raw sums (no one-increment floor), in one scan. + /// + /// What [`crate::beacon::helpers::target_balances`] caches per block: a + /// floored total cannot be extended by a later block's additions. Fails + /// like [`Self::compute`]. + pub fn compute_raw_target(state: &BeaconState) -> Result<(Gwei, Gwei)> { + let raw = scan_raw(state, true, |_, _, _| {})?; + Ok(( + raw.previous_flags[constants::TIMELY_TARGET_FLAG_INDEX], + raw.current_target, + )) + } +} + +/// [`ParticipationTotals`] before the one-increment floor. +struct RawTotals { + total_active: Gwei, + previous_flags: [Gwei; FLAG_COUNT], + current_target: Gwei, +} + +/// [`scan_raw`] with the specification's one-increment floor applied. +fn scan( + state: &BeaconState, + current_target: bool, + each: impl FnMut(usize, EpochFlags, Gwei), +) -> Result { + let raw = scan_raw(state, current_target, each)?; + let floor = |total: Gwei| total.max(preset::EFFECTIVE_BALANCE_INCREMENT); + Ok(ParticipationTotals { + total_active_balance: floor(raw.total_active), + previous_epoch_flags: raw.previous_flags.map(floor), + current_epoch_target: floor(raw.current_target), + }) } /// Walks the registry once, calling `each(index, flags, effective_balance)` per -/// validator, and returns the totals. +/// validator, and returns the raw totals. /// /// The only place the membership predicates live. `current_target` says /// whether the current epoch's list is read at all: the specification's @@ -112,11 +148,11 @@ impl ParticipationTotals { /// At the genesis epoch "previous" and "current" coincide and the /// specification reads the CURRENT list for both, so the previous epoch's /// list aliases it there. -fn scan( +fn scan_raw( state: &BeaconState, current_target: bool, mut each: impl FnMut(usize, EpochFlags, Gwei), -) -> Result { +) -> Result { let (previous_list, current_list, _) = state.altair_validator_lists()?; let current_epoch = get_current_epoch(state); let previous_epoch = get_previous_epoch(state); @@ -197,11 +233,10 @@ fn scan( return Err(error); } - let floor = |total: Gwei| total.max(preset::EFFECTIVE_BALANCE_INCREMENT); - Ok(ParticipationTotals { - total_active_balance: floor(total_active), - previous_epoch_flags: previous_flags.map(floor), - current_epoch_target: floor(current_target_balance), + Ok(RawTotals { + total_active, + previous_flags, + current_target: current_target_balance, }) } diff --git a/crates/blockchain/state_transition/src/beacon/helpers/target_balances.rs b/crates/blockchain/state_transition/src/beacon/helpers/target_balances.rs new file mode 100644 index 000000000..5ec62438b --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/target_balances.rs @@ -0,0 +1,236 @@ +//! Incremental unslashed TIMELY_TARGET balances. +//! +//! `compute_pulled_up_tip` weighs justification for every imported block, and +//! the weighing needs the balance that cast a timely target vote in the +//! previous and current epoch. Summing it is a pass over the whole registry +//! ([`ParticipationTotals::compute_raw_target`]); this module makes the common +//! case O(block) instead, the way Lighthouse's `ProgressiveBalancesCache` +//! does, with the totals held by block root in +//! [`ethlambda_storage::TargetBalanceCache`]. +//! +//! # Why a parent's totals plus the block's delta is exact +//! +//! Within one epoch a validator's contribution to either total can only change +//! three ways: +//! +//! 1. Its TIMELY_TARGET flag goes from unset to set. Only `process_attestation` +//! writes participation flags during block processing (the sync aggregate, +//! withdrawals, deposits, the execution requests and gloas's builder and +//! payload operations touch balances, queues and exit epochs, never a +//! flag), and [`TargetBalanceDelta::record_new_target`] is called from every +//! fork's copy of it. +//! 2. Its effective balance changes. Only `process_effective_balance_updates` +//! writes one, at an epoch boundary, plus `upgrade_to_electra` zeroing +//! never-activated validators, also at a boundary. Deposits append +//! validators that are not active in the previous or current epoch (an +//! activation epoch is always assigned in the future), so a new entry never +//! contributes. +//! 3. It is slashed, which removes it from the unslashed total. +//! `slash_validator` is reachable only through a block's proposer and +//! attester slashings, so [`block_has_slashings`] sends such a block to the +//! full scan instead of threading a second accumulator through them. Rare +//! enough that the scan costs nothing on average. +//! +//! Which validators are counted cannot change either: membership needs +//! "active in the epoch", and exits only assign future exit epochs. +//! +//! So a block whose parent's post-state shares its epoch (no `process_slots` +//! epoch boundary crossed) has exactly the parent's totals plus the delta. At a +//! boundary, on a cache miss, after a restart, or for a block with slashings, +//! the totals are computed in full once and cached. +//! +//! The first two epochs are skipped entirely: justification does not run +//! there, and the previous and current epoch coincide at genesis, where the +//! scan reads the current list for both. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use ethlambda_storage::{TargetBalanceCache, TargetBalances}; + +use crate::beacon::containers::BeaconState; +use crate::beacon::containers::{SignedBeaconBlock, Validator}; +use crate::beacon::error::Result; +use crate::beacon::primitives::{Epoch, Gwei, Root}; + +use super::accessors::get_current_epoch; +use super::participation::ParticipationTotals; + +/// What one block's attestations added to the previous and current epoch's +/// unslashed TIMELY_TARGET balance. +/// +/// Written by `process_attestation` through a shared reference (hence the +/// atomics, relaxed since one thread writes), so it can ride in +/// [`BlockProcessingOptions`](crate::beacon::stf::block::BlockProcessingOptions), +/// which is `Copy`, and keep that type `Send` for the async block producers. +#[derive(Debug, Default)] +pub struct TargetBalanceDelta { + previous: AtomicU64, + current: AtomicU64, +} + +impl PartialEq for TargetBalanceDelta { + fn eq(&self, other: &Self) -> bool { + self.previous() == other.previous() && self.current() == other.current() + } +} + +impl Eq for TargetBalanceDelta {} + +impl TargetBalanceDelta { + /// Records that `validator` just earned TIMELY_TARGET in the current or + /// previous epoch's participation. A slashed validator is not counted, as + /// the specification's unslashed participating set excludes it. + pub fn record_new_target(&self, current_epoch_target: bool, validator: &Validator) { + if validator.slashed { + return; + } + let side = if current_epoch_target { + &self.current + } else { + &self.previous + }; + let total = side + .load(Ordering::Relaxed) + .saturating_add(validator.effective_balance); + side.store(total, Ordering::Relaxed); + } + + /// The balance added to the previous epoch's total. + pub fn previous(&self) -> Gwei { + self.previous.load(Ordering::Relaxed) + } + + /// The balance added to the current epoch's total. + pub fn current(&self) -> Gwei { + self.current.load(Ordering::Relaxed) + } + + /// `parent` plus this delta, for the block's own epoch. + fn applied_to(&self, parent: TargetBalances) -> TargetBalances { + TargetBalances { + epoch: parent.epoch, + previous: parent.previous.saturating_add(self.previous()), + current: parent.current.saturating_add(self.current()), + } + } +} + +/// Whether justification weighs target balances for a state in `epoch`; the +/// totals are only maintained where they are read. +pub fn tracks_epoch(epoch: Epoch) -> bool { + epoch > crate::beacon::constants::GENESIS_EPOCH + 1 +} + +/// The totals of `state` from one scan of the registry. +/// +/// Fails like [`ParticipationTotals::compute`]. Phase0 states have no +/// participation flags and fail through `altair_validator_lists`. +pub fn compute_target_balances(state: &BeaconState) -> Result { + let (previous, current) = ParticipationTotals::compute_raw_target(state)?; + Ok(TargetBalances { + epoch: get_current_epoch(state), + previous, + current, + }) +} + +/// The block's post-state totals when its parent's are cached for the same +/// epoch: the parent's plus `delta`. `None` means the caller must scan. +/// +/// `block_epoch` is the epoch of the block's slot. An entry for a different +/// epoch means `process_slots` crossed a boundary between parent and block, +/// where effective balances and the participation lists changed. +pub fn extend_parent_totals( + cache: &TargetBalanceCache, + parent_root: &Root, + block_epoch: Epoch, + delta: &TargetBalanceDelta, +) -> Option { + let parent = cache.get(parent_root)?; + (parent.epoch == block_epoch && tracks_epoch(block_epoch)).then(|| delta.applied_to(parent)) +} + +/// Whether the block carries an operation that slashes a validator; see the +/// module documentation for why such a block is not extended incrementally. +pub fn block_has_slashings(block: &SignedBeaconBlock) -> bool { + match block { + SignedBeaconBlock::Phase0(b) => { + !b.message.body.proposer_slashings.is_empty() + || !b.message.body.attester_slashings.is_empty() + } + SignedBeaconBlock::Altair(b) => { + !b.message.body.proposer_slashings.is_empty() + || !b.message.body.attester_slashings.is_empty() + } + SignedBeaconBlock::Bellatrix(b) => { + !b.message.body.proposer_slashings.is_empty() + || !b.message.body.attester_slashings.is_empty() + } + SignedBeaconBlock::Capella(b) => { + !b.message.body.proposer_slashings.is_empty() + || !b.message.body.attester_slashings.is_empty() + } + SignedBeaconBlock::Deneb(b) => { + !b.message.body.proposer_slashings.is_empty() + || !b.message.body.attester_slashings.is_empty() + } + SignedBeaconBlock::Electra(b) | SignedBeaconBlock::Fulu(b) => { + !b.message.body.proposer_slashings.is_empty() + || !b.message.body.attester_slashings.is_empty() + } + SignedBeaconBlock::Gloas(b) => { + !b.message.body.proposer_slashings.is_empty() + || !b.message.body.attester_slashings.is_empty() + } + // Not a Beacon Chain block: nothing here ever carries one. + SignedBeaconBlock::Lean(_) => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn validator(effective_balance: Gwei, slashed: bool) -> Validator { + Validator { + effective_balance, + slashed, + ..Default::default() + } + } + + #[test] + fn a_delta_skips_slashed_validators_and_splits_by_epoch() { + let delta = TargetBalanceDelta::default(); + delta.record_new_target(false, &validator(32, false)); + delta.record_new_target(true, &validator(16, false)); + delta.record_new_target(true, &validator(64, true)); + assert_eq!((delta.previous(), delta.current()), (32, 16)); + } + + #[test] + fn a_parent_is_extended_only_within_its_own_tracked_epoch() { + let cache = TargetBalanceCache::default(); + let parent = Root::repeat_byte(1); + let totals = TargetBalances { + epoch: 5, + previous: 100, + current: 10, + }; + cache.insert(parent, totals); + let delta = TargetBalanceDelta::default(); + delta.record_new_target(true, &validator(7, false)); + + let extended = extend_parent_totals(&cache, &parent, 5, &delta).unwrap(); + assert_eq!((extended.previous, extended.current), (100, 17)); + assert_eq!(extend_parent_totals(&cache, &parent, 6, &delta), None); + assert_eq!( + extend_parent_totals(&cache, &Root::repeat_byte(2), 5, &delta), + None + ); + + let early = Root::repeat_byte(3); + cache.insert(early, TargetBalances { epoch: 1, ..totals }); + assert_eq!(extend_parent_totals(&cache, &early, 1, &delta), None); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/altair.rs b/crates/blockchain/state_transition/src/beacon/stf/altair.rs index 2b33e79be..0cc5ab80c 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/altair.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/altair.rs @@ -41,6 +41,7 @@ use crate::beacon::helpers::altair::{ use crate::beacon::helpers::attestation::{get_indexed_attestation, is_valid_indexed_attestation}; use crate::beacon::helpers::misc::{compute_epoch_at_slot, compute_signing_root}; use crate::beacon::helpers::mutators::{decrease_balance, increase_balance}; +use crate::beacon::helpers::target_balances::TargetBalanceDelta; use crate::beacon::preset; use crate::beacon::primitives::{BlsPubkey, Gwei, ParticipationFlags, ValidatorIndex}; use std::collections::HashMap; @@ -84,6 +85,7 @@ pub fn process_attestation( attestation: &phase0::Attestation, committees: &CommitteeCache, active_balances: &ActiveBalanceCache, + target_delta: Option<&TargetBalanceDelta>, ) -> Result<()> { let data = attestation.data; let current_epoch = get_current_epoch(state); @@ -211,6 +213,11 @@ pub fn process_attestation( .checked_add(reward) .ok_or(Error::ArithmeticOverflow("proposer_reward_numerator"))?; } + if let Some(delta) = target_delta + && has_flag(new_flags, constants::TIMELY_TARGET_FLAG_INDEX) + { + delta.record_new_target(current_epoch_target, state.validator(index)?); + } if new_flags != 0 { updates.push((index, new_flags)); } @@ -424,6 +431,7 @@ mod tests { use crate::beacon::containers::shared::{AttestationData, Checkpoint, Validator}; 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::helpers::participation::ParticipationTotals; use crate::beacon::primitives::{ BLS_SIGNATURE_SIZE, BlsPubkey, BlsSignature, Bytes32, HashTreeRoot as _, Root, }; @@ -598,6 +606,7 @@ mod tests { &attestation, &CommitteeCache::default(), &ActiveBalanceCache::default(), + None, ) .unwrap(); @@ -634,6 +643,7 @@ mod tests { &attestation, &CommitteeCache::default(), &ActiveBalanceCache::default(), + None, ) .unwrap(); let balance_after_replay = state.balance(proposer_index).unwrap(); @@ -643,6 +653,102 @@ mod tests { ); } + /// A signed attestation by every member of `data_slot`'s first committee, + /// targeting that slot's epoch, for the all-zero synthetic root history. + fn full_committee_attestation( + state: &BeaconState, + secret_keys: &[SecretKey], + data_slot: u64, + ) -> phase0::Attestation { + let target_epoch = compute_epoch_at_slot(data_slot); + let committee = get_beacon_committee(state, data_slot, 0).expect("committee exists"); + let data = AttestationData { + slot: data_slot, + index: 0, + beacon_block_root: Root::ZERO, + source: Checkpoint::default(), + target: Checkpoint { + epoch: target_epoch, + root: Root::ZERO, + }, + }; + let mut aggregation_bits = + crate::beacon::containers::phase0::AggregationBits::with_length(committee.len()) + .expect("committee.len() is far below MAX_VALIDATORS_PER_COMMITTEE"); + for position in 0..committee.len() { + aggregation_bits.set(position, true).unwrap(); + } + let domain = get_domain(state, constants::DOMAIN_BEACON_ATTESTER, Some(target_epoch)); + let signing_root = compute_signing_root(data.hash_tree_root(), domain); + const DST: &[u8] = b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"; + let signatures: Vec = committee + .iter() + .map(|&index| { + BlsSignature( + secret_keys[index as usize] + .sign(signing_root.as_slice(), DST, &[]) + .to_bytes(), + ) + }) + .collect(); + phase0::Attestation { + aggregation_bits, + data, + signature: bls::aggregate(&signatures).unwrap(), + } + } + + /// The invariant `compute_pulled_up_tip` relies on: the unslashed + /// timely-target balances after a block equal the balances before it plus + /// the delta `process_attestation` collected, for the previous and current + /// epoch alike, with a slashed attester left out and a repeated vote + /// counted once. + #[test] + fn the_target_delta_is_what_a_registry_scan_gains() { + let (mut state, secret_keys) = altair_state_with_keypairs(64); + // Slot after the first slot of epoch 1: both the previous epoch's last + // slot and the current epoch's first slot are attestable. + *state.slot_mut() = preset::SLOTS_PER_EPOCH + 1; + let previous_slot = preset::SLOTS_PER_EPOCH - 1; + let current_slot = preset::SLOTS_PER_EPOCH; + + let previous_committee = get_beacon_committee(&state, previous_slot, 0).unwrap(); + state.validator_mut(previous_committee[0]).unwrap().slashed = true; + + let before = ParticipationTotals::compute_raw_target(&state).unwrap(); + let committees = CommitteeCache::default(); + let active_balances = ActiveBalanceCache::default(); + let delta = TargetBalanceDelta::default(); + for slot in [previous_slot, current_slot] { + let attestation = full_committee_attestation(&state, &secret_keys, slot); + // Twice: the second pass sets no new flag and must add nothing. + for _ in 0..2 { + process_attestation( + &mut state, + &attestation, + &committees, + &active_balances, + Some(&delta), + ) + .unwrap(); + } + } + let after = ParticipationTotals::compute_raw_target(&state).unwrap(); + + assert!( + delta.previous() > 0 && delta.current() > 0, + "both epochs voted" + ); + assert_eq!(after.0, before.0 + delta.previous()); + assert_eq!(after.1, before.1 + delta.current()); + let slashed_share = (previous_committee.len() as u64 - 1) * preset::MAX_EFFECTIVE_BALANCE; + assert_eq!( + delta.previous(), + slashed_share, + "the slashed attester adds nothing" + ); + } + #[test] fn process_sync_aggregate_rewards_participants_and_penalizes_a_repeated_non_participant() { // Five validators: the first four hold one participating seat each, diff --git a/crates/blockchain/state_transition/src/beacon/stf/bellatrix.rs b/crates/blockchain/state_transition/src/beacon/stf/bellatrix.rs index e9287513e..fad66e0c7 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/bellatrix.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/bellatrix.rs @@ -94,6 +94,7 @@ pub fn process_block( config, committees, active_balances, + options.target_delta, )?; 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 dcaa32684..534eaa0af 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/block.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/block.rs @@ -29,6 +29,7 @@ use crate::beacon::helpers::accessors::{ }; use crate::beacon::helpers::math::xor; use crate::beacon::helpers::misc::compute_signing_root; +use crate::beacon::helpers::target_balances::TargetBalanceDelta; use crate::beacon::preset; use crate::beacon::primitives::{BlsSignature, HashTreeRoot as _, Root, Slot, ValidatorIndex}; @@ -158,6 +159,7 @@ pub fn process_block_phase0( config, committees, active_balances, + options.target_delta, )?; Ok(()) } @@ -195,6 +197,7 @@ pub fn process_block_altair( config, committees, active_balances, + options.target_delta, )?; super::altair::process_sync_aggregate(state, &block.body.sync_aggregate, active_balances)?; Ok(()) @@ -261,8 +264,15 @@ pub fn process_block_header( /// What `process_block` may leave unchecked. The default checks everything the /// specification does, and is the only value block import uses. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct BlockProcessingOptions { +pub struct BlockProcessingOptions<'a> { pub randao: RandaoVerification, + /// Collects what the block's attestations add to the unslashed + /// TIMELY_TARGET balances, for fork choice's incremental pulled-up tip + /// (see [`crate::beacon::helpers::target_balances`]). `None` for every + /// caller that does not keep those totals. Carried here rather than as a + /// parameter of its own because this is the one per-call value every fork's + /// driver already receives. + pub target_delta: Option<&'a TargetBalanceDelta>, } /// Whether `process_randao` verifies the proposer's reveal before mixing it in. diff --git a/crates/blockchain/state_transition/src/beacon/stf/capella.rs b/crates/blockchain/state_transition/src/beacon/stf/capella.rs index f773d6da7..a169b6447 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/capella.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/capella.rs @@ -56,6 +56,7 @@ use crate::beacon::helpers::capella::{ }; use crate::beacon::helpers::misc::{compute_domain, compute_signing_root}; use crate::beacon::helpers::mutators::decrease_balance; +use crate::beacon::helpers::target_balances::TargetBalanceDelta; use crate::beacon::preset; use crate::beacon::primitives::{H256, HashTreeRoot as _}; @@ -103,6 +104,7 @@ pub fn process_block( config, committees, active_balances, + options.target_delta, )?; super::altair::process_sync_aggregate(state, &block.body.sync_aggregate, active_balances)?; Ok(()) @@ -369,6 +371,7 @@ pub fn process_operations( config: &Config, committees: &CommitteeCache, active_balances: &ActiveBalanceCache, + target_delta: Option<&TargetBalanceDelta>, ) -> Result<()> { super::operations::process_operations( state, @@ -380,6 +383,7 @@ pub fn process_operations( config, committees, active_balances, + target_delta, )?; 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 72b5ac946..18a4dcb5a 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/deneb.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/deneb.rs @@ -48,6 +48,7 @@ use crate::beacon::helpers::mutators::{ decrease_balance, increase_balance, initiate_validator_exit, }; use crate::beacon::helpers::predicates::is_active_validator; +use crate::beacon::helpers::target_balances::TargetBalanceDelta; use crate::beacon::preset; use crate::beacon::primitives::{ Bytes32, Gwei, H256, HashTreeRoot as _, KzgCommitment, ParticipationFlags, ValidatorIndex, @@ -109,6 +110,7 @@ pub fn process_block( config, committees, active_balances, + options.target_delta, )?; super::altair::process_sync_aggregate(state, &block.body.sync_aggregate, active_balances)?; Ok(()) @@ -152,6 +154,7 @@ fn process_operations( config: &Config, committees: &CommitteeCache, active_balances: &ActiveBalanceCache, + target_delta: Option<&TargetBalanceDelta>, ) -> Result<()> { let outstanding = state .eth1_data() @@ -172,7 +175,13 @@ fn process_operations( super::operations::process_attester_slashing(state, attester_slashing, config)?; } for attestation in attestations { - process_attestation(state, attestation, committees, active_balances)?; + process_attestation( + state, + attestation, + committees, + active_balances, + target_delta, + )?; } for deposit in deposits { super::operations::process_deposit(state, deposit, config)?; @@ -287,6 +296,7 @@ pub fn process_attestation( attestation: &phase0::Attestation, committees: &CommitteeCache, active_balances: &ActiveBalanceCache, + target_delta: Option<&TargetBalanceDelta>, ) -> Result<()> { let data = attestation.data; let current_epoch = get_current_epoch(state); @@ -411,6 +421,11 @@ pub fn process_attestation( .checked_add(reward) .ok_or(Error::ArithmeticOverflow("proposer_reward_numerator"))?; } + if let Some(delta) = target_delta + && has_flag(new_flags, constants::TIMELY_TARGET_FLAG_INDEX) + { + delta.record_new_target(current_epoch_target, state.validator(index)?); + } if new_flags != 0 { updates.push((index, new_flags)); } diff --git a/crates/blockchain/state_transition/src/beacon/stf/electra.rs b/crates/blockchain/state_transition/src/beacon/stf/electra.rs index 9b3519caf..e586e4850 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/electra.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/electra.rs @@ -76,6 +76,7 @@ use crate::beacon::helpers::misc::{ }; use crate::beacon::helpers::mutators::{decrease_balance, increase_balance}; use crate::beacon::helpers::predicates::{is_active_validator, is_slashable_attestation_data}; +use crate::beacon::helpers::target_balances::TargetBalanceDelta; use crate::beacon::preset; use crate::beacon::primitives::{ BlsPubkey, BlsSignature, Bytes32, Gwei, HashTreeRoot as _, ParticipationFlags, ValidatorIndex, @@ -641,6 +642,7 @@ pub fn process_attestation( attestation: &electra::Attestation, committees: &CommitteeCache, active_balances: &ActiveBalanceCache, + target_delta: Option<&TargetBalanceDelta>, ) -> Result<()> { let data = attestation.data; let current_epoch = get_current_epoch(state); @@ -792,6 +794,11 @@ pub fn process_attestation( .checked_add(reward) .ok_or(Error::ArithmeticOverflow("proposer_reward_numerator"))?; } + if let Some(delta) = target_delta + && has_flag(new_flags, constants::TIMELY_TARGET_FLAG_INDEX) + { + delta.record_new_target(current_epoch_target, state.validator(index)?); + } if new_flags != 0 { updates.push((index, new_flags)); } @@ -1785,6 +1792,7 @@ pub fn process_operations( config: &Config, committees: &CommitteeCache, active_balances: &ActiveBalanceCache, + target_delta: Option<&TargetBalanceDelta>, ) -> Result<()> { let deposit_requests_start_index = block_ref(state, "process_operations")?.deposit_requests_start_index(); @@ -1810,7 +1818,13 @@ pub fn process_operations( } // [Modified in Electra:EIP7549] for attestation in body.attestations.iter() { - process_attestation(state, attestation, committees, active_balances)?; + process_attestation( + state, + attestation, + committees, + active_balances, + target_delta, + )?; } for deposit in body.deposits.iter() { process_deposit(state, deposit, config)?; @@ -1865,7 +1879,14 @@ 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, active_balances)?; + process_operations( + state, + &block.body, + config, + committees, + active_balances, + options.target_delta, + )?; super::altair::process_sync_aggregate(state, &block.body.sync_aggregate, active_balances)?; Ok(()) } diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/justification.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/justification.rs index ecbadf293..c82b2a5c6 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/epoch/justification.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/justification.rs @@ -18,7 +18,7 @@ use crate::beacon::error::{Error, Result}; use crate::beacon::helpers::accessors::{ get_block_root, get_current_epoch, get_previous_epoch, get_total_active_balance, }; -use crate::beacon::primitives::Gwei; +use crate::beacon::primitives::{Epoch, Gwei, Root}; use super::{JUSTIFICATION_BITS, get_attesting_balance, get_matching_target_attestations}; @@ -58,99 +58,148 @@ pub fn process_justification_and_finalization( /// The target balances are passed in rather than recomputed here so this can be /// exercised (and reasoned about) independently of attestation matching, which /// is what [`process_justification_and_finalization`] uses it for. +/// +/// Extracts the fields the weighing reads and writes into a +/// [`JustificationState`], weighs, and writes them back, so epoch processing +/// and fork choice's state-free pulled-up tip run the same rules. The write-back +/// happens on an error too: the weighing used to mutate the state in place, and +/// a failure partway left what it had already written. pub fn weigh_justification_and_finalization( state: &mut BeaconState, total_active_balance: Gwei, previous_epoch_target_balance: Gwei, current_epoch_target_balance: Gwei, ) -> Result<()> { - let previous_epoch = get_previous_epoch(state); - let current_epoch = get_current_epoch(state); - let old_previous_justified_checkpoint = state.previous_justified_checkpoint(); - let old_current_justified_checkpoint = state.current_justified_checkpoint(); - - // Process justifications - *state.previous_justified_checkpoint_mut() = state.current_justified_checkpoint(); - - // Age the bitfield by one epoch before folding in this epoch's result. Index - // 0 always names the epoch just processed, so ageing moves every bit toward - // a HIGHER index (older epochs); the oldest bit falls off the top and is - // lost. Reading index `i - 1` before writing index `i`, from the top down, - // reproduces the spec's simultaneous slice assignment without a temporary - // copy. - for i in (1..JUSTIFICATION_BITS).rev() { - let older = state - .justification_bits() - .get(i - 1) - .expect("index is within JUSTIFICATION_BITS_LENGTH"); - state - .justification_bits_mut() - .set(i, older) - .expect("index is within JUSTIFICATION_BITS_LENGTH"); - } - state - .justification_bits_mut() - .set(0, false) - .expect("index is within JUSTIFICATION_BITS_LENGTH"); - - if meets_justification_threshold(previous_epoch_target_balance, total_active_balance)? { - *state.current_justified_checkpoint_mut() = Checkpoint { - epoch: previous_epoch, - root: get_block_root(state, previous_epoch)?, - }; - state - .justification_bits_mut() - .set(1, true) - .expect("index is within JUSTIFICATION_BITS_LENGTH"); - } - if meets_justification_threshold(current_epoch_target_balance, total_active_balance)? { - *state.current_justified_checkpoint_mut() = Checkpoint { - epoch: current_epoch, - root: get_block_root(state, current_epoch)?, - }; - state - .justification_bits_mut() - .set(0, true) - .expect("index is within JUSTIFICATION_BITS_LENGTH"); - } + let mut justification = JustificationState::from_state(state); + let result = justification.weigh( + total_active_balance, + previous_epoch_target_balance, + current_epoch_target_balance, + |epoch| get_block_root(state, epoch), + ); + justification.write_back(state); + result +} - // Process finalizations - // - // Read every bit the four rules need before touching `finalized_checkpoint`, - // since holding a `&JustificationBits` borrow across those writes would - // conflict with the `&mut BeaconState` each rule needs. - let (justifies_1_2_3, justifies_1_2, justifies_0_1_2, justifies_0_1) = { - let bits = state.justification_bits(); - let bit = |i: usize| { - bits.get(i) - .expect("index is within JUSTIFICATION_BITS_LENGTH") - }; - ( - bit(1) && bit(2) && bit(3), - bit(1) && bit(2), - bit(0) && bit(1) && bit(2), - bit(0) && bit(1), - ) - }; +/// Exactly the fields [`weigh_justification_and_finalization`] reads and +/// writes, so a caller that only wants the outcome (fork choice's pulled-up +/// tip) can weigh without cloning a state. The counterpart of Lighthouse's +/// `JustificationAndFinalizationState`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct JustificationState { + /// The state's current epoch. + pub current_epoch: Epoch, + /// The state's previous epoch. + pub previous_epoch: Epoch, + /// `justification_bits`, index 0 naming the epoch just processed. + pub justification_bits: [bool; JUSTIFICATION_BITS], + pub previous_justified_checkpoint: Checkpoint, + pub current_justified_checkpoint: Checkpoint, + pub finalized_checkpoint: Checkpoint, +} - // The 2nd/3rd/4th most recent epochs are justified, the 2nd using the 4th as source - if justifies_1_2_3 && old_previous_justified_checkpoint.epoch + 3 == current_epoch { - *state.finalized_checkpoint_mut() = old_previous_justified_checkpoint; - } - // The 2nd/3rd most recent epochs are justified, the 2nd using the 3rd as source - if justifies_1_2 && old_previous_justified_checkpoint.epoch + 2 == current_epoch { - *state.finalized_checkpoint_mut() = old_previous_justified_checkpoint; - } - // The 1st/2nd/3rd most recent epochs are justified, the 1st using the 3rd as source - if justifies_0_1_2 && old_current_justified_checkpoint.epoch + 2 == current_epoch { - *state.finalized_checkpoint_mut() = old_current_justified_checkpoint; +impl JustificationState { + /// Copies the weighed fields out of `state`. + pub fn from_state(state: &BeaconState) -> Self { + let bits = state.justification_bits(); + Self { + current_epoch: get_current_epoch(state), + previous_epoch: get_previous_epoch(state), + justification_bits: std::array::from_fn(|i| { + bits.get(i) + .expect("index is within JUSTIFICATION_BITS_LENGTH") + }), + previous_justified_checkpoint: state.previous_justified_checkpoint(), + current_justified_checkpoint: state.current_justified_checkpoint(), + finalized_checkpoint: state.finalized_checkpoint(), + } } - // The 1st/2nd most recent epochs are justified, the 1st using the 2nd as source - if justifies_0_1 && old_current_justified_checkpoint.epoch + 1 == current_epoch { - *state.finalized_checkpoint_mut() = old_current_justified_checkpoint; + + /// Writes the weighed fields back into `state`. + pub fn write_back(&self, state: &mut BeaconState) { + *state.previous_justified_checkpoint_mut() = self.previous_justified_checkpoint; + *state.current_justified_checkpoint_mut() = self.current_justified_checkpoint; + *state.finalized_checkpoint_mut() = self.finalized_checkpoint; + for (i, bit) in self.justification_bits.iter().enumerate() { + state + .justification_bits_mut() + .set(i, *bit) + .expect("index is within JUSTIFICATION_BITS_LENGTH"); + } } - Ok(()) + /// The specification's `weigh_justification_and_finalization` body. + /// + /// `block_root` is `get_block_root(state, epoch)` for the state the fields + /// came from; asked only for an epoch that meets the threshold, so a state + /// that cannot answer for an epoch that does not justify is not failed. + pub fn weigh( + &mut self, + total_active_balance: Gwei, + previous_epoch_target_balance: Gwei, + current_epoch_target_balance: Gwei, + block_root: impl Fn(Epoch) -> Result, + ) -> Result<()> { + let previous_epoch = self.previous_epoch; + let current_epoch = self.current_epoch; + let old_previous_justified_checkpoint = self.previous_justified_checkpoint; + let old_current_justified_checkpoint = self.current_justified_checkpoint; + + // Process justifications + self.previous_justified_checkpoint = self.current_justified_checkpoint; + + // Age the bitfield by one epoch before folding in this epoch's result. + // Index 0 always names the epoch just processed, so ageing moves every + // bit toward a HIGHER index (older epochs); the oldest bit falls off + // the top and is lost. Reading index `i - 1` before writing index `i`, + // from the top down, reproduces the spec's simultaneous slice + // assignment without a temporary copy. + for i in (1..JUSTIFICATION_BITS).rev() { + self.justification_bits[i] = self.justification_bits[i - 1]; + } + self.justification_bits[0] = false; + + if meets_justification_threshold(previous_epoch_target_balance, total_active_balance)? { + self.current_justified_checkpoint = Checkpoint { + epoch: previous_epoch, + root: block_root(previous_epoch)?, + }; + self.justification_bits[1] = true; + } + if meets_justification_threshold(current_epoch_target_balance, total_active_balance)? { + self.current_justified_checkpoint = Checkpoint { + epoch: current_epoch, + root: block_root(current_epoch)?, + }; + self.justification_bits[0] = true; + } + + // Process finalizations + let bit = |i: usize| self.justification_bits[i]; + let justifies_1_2_3 = bit(1) && bit(2) && bit(3); + let justifies_1_2 = bit(1) && bit(2); + let justifies_0_1_2 = bit(0) && bit(1) && bit(2); + let justifies_0_1 = bit(0) && bit(1); + + // The 2nd/3rd/4th most recent epochs are justified, the 2nd using the 4th as source + if justifies_1_2_3 && old_previous_justified_checkpoint.epoch + 3 == current_epoch { + self.finalized_checkpoint = old_previous_justified_checkpoint; + } + // The 2nd/3rd most recent epochs are justified, the 2nd using the 3rd as source + if justifies_1_2 && old_previous_justified_checkpoint.epoch + 2 == current_epoch { + self.finalized_checkpoint = old_previous_justified_checkpoint; + } + // The 1st/2nd/3rd most recent epochs are justified, the 1st using the 3rd as source + if justifies_0_1_2 && old_current_justified_checkpoint.epoch + 2 == current_epoch { + self.finalized_checkpoint = old_current_justified_checkpoint; + } + // The 1st/2nd most recent epochs are justified, the 1st using the 2nd as source + if justifies_0_1 && old_current_justified_checkpoint.epoch + 1 == current_epoch { + self.finalized_checkpoint = old_current_justified_checkpoint; + } + + Ok(()) + } } /// Whether `balance` covers at least two-thirds of `total_active_balance`. diff --git a/crates/blockchain/state_transition/src/beacon/stf/fulu.rs b/crates/blockchain/state_transition/src/beacon/stf/fulu.rs index 1a8bc3b58..85c91f134 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/fulu.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/fulu.rs @@ -48,6 +48,7 @@ use crate::beacon::helpers::accessors::{ }; use crate::beacon::helpers::electra::pending_queue_fields; use crate::beacon::helpers::fulu::{fulu_state, fulu_state_ref}; +use crate::beacon::helpers::target_balances::TargetBalanceDelta; use crate::beacon::primitives::{Bytes32, HashTreeRoot as _}; use super::ExecutionEngine; @@ -81,7 +82,14 @@ 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, active_balances)?; + process_operations( + state, + &block.body, + config, + committees, + active_balances, + options.target_delta, + )?; super::altair::process_sync_aggregate(state, &block.body.sync_aggregate, active_balances)?; Ok(()) } @@ -224,6 +232,7 @@ pub fn process_operations( config: &Config, committees: &CommitteeCache, active_balances: &ActiveBalanceCache, + target_delta: Option<&TargetBalanceDelta>, ) -> Result<()> { // [Modified in Fulu] verify(body.deposits.is_empty(), "len(body.deposits) == 0")?; @@ -235,7 +244,13 @@ 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, active_balances)?; + super::electra::process_attestation( + state, + attestation, + committees, + active_balances, + target_delta, + )?; } // [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 0ef4c1c8c..12f99811c 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/gloas.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/gloas.rs @@ -71,6 +71,7 @@ use crate::beacon::helpers::gloas::{ use crate::beacon::helpers::misc::{compute_domain, compute_epoch_at_slot, compute_signing_root}; use crate::beacon::helpers::mutators::{decrease_balance, increase_balance, slash_validator}; use crate::beacon::helpers::predicates::is_slashable_attestation_data; +use crate::beacon::helpers::target_balances::TargetBalanceDelta; use crate::beacon::preset; use crate::beacon::primitives::{ Bytes32, ExecutionAddress, Gwei, HashTreeRoot as _, ParticipationFlags, Root, Slot, @@ -133,6 +134,7 @@ pub fn process_block( config, committees, active_balances, + options.target_delta, )?; super::altair::process_sync_aggregate(state, &block.body.sync_aggregate, active_balances)?; Ok(()) @@ -1303,6 +1305,7 @@ pub fn process_attestation( parent_slot: Slot, committees: &CommitteeCache, active_balances: &ActiveBalanceCache, + target_delta: Option<&TargetBalanceDelta>, ) -> Result<()> { let data = attestation.data; let current_epoch = get_current_epoch(state); @@ -1469,6 +1472,11 @@ pub fn process_attestation( Error::ArithmeticOverflow("process_attestation: proposer_reward_numerator"), )?; } + if let Some(delta) = target_delta + && has_flag(new_flags, constants::TIMELY_TARGET_FLAG_INDEX) + { + delta.record_new_target(current_epoch_target, state.validator(index)?); + } let will_set_new_flag = new_flags != 0; if will_set_new_flag @@ -1588,6 +1596,7 @@ pub fn process_operations( config: &Config, committees: &CommitteeCache, active_balances: &ActiveBalanceCache, + target_delta: Option<&TargetBalanceDelta>, ) -> Result<()> { verify( body.deposits.is_empty(), @@ -1629,7 +1638,14 @@ pub fn process_operations( } // [Modified in Gloas:EIP7732] for attestation in body.attestations.iter() { - process_attestation(state, attestation, parent_slot, committees, active_balances)?; + process_attestation( + state, + attestation, + parent_slot, + committees, + active_balances, + target_delta, + )?; } for voluntary_exit in body.voluntary_exits.iter() { crate::beacon::stf::electra::process_voluntary_exit(state, voluntary_exit, config)?; @@ -1929,6 +1945,7 @@ mod tests { &config, &committees, &ActiveBalanceCache::default(), + None, ); assert!( matches!(result, Err(Error::SpecAssert(message)) if message == expected_message), diff --git a/crates/blockchain/state_transition/src/beacon/stf/mod.rs b/crates/blockchain/state_transition/src/beacon/stf/mod.rs index 3dba6eef8..7c212a0b7 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/mod.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/mod.rs @@ -135,9 +135,34 @@ pub fn state_transition( engine: &ExecutionEngine, committees: &CommitteeCache, active_balances: &ActiveBalanceCache, +) -> Result<()> { + state_transition_with_options( + state, + signed_block, + validate_result, + config, + engine, + committees, + active_balances, + block::BlockProcessingOptions::default(), + ) +} + +/// [`state_transition`] with explicit [`block::BlockProcessingOptions`], for +/// the caller that collects the block's target-balance delta. +#[allow(clippy::too_many_arguments)] +pub fn state_transition_with_options( + state: &mut BeaconState, + signed_block: &containers::SignedBeaconBlock, + validate_result: bool, + config: &Config, + engine: &ExecutionEngine, + committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, + options: block::BlockProcessingOptions, ) -> Result<()> { process_slots(state, signed_block.slot(), config)?; - apply_block( + apply_block_with_options( state, signed_block, validate_result, @@ -145,6 +170,7 @@ pub fn state_transition( engine, committees, active_balances, + options, ) } @@ -165,6 +191,30 @@ pub fn apply_block( engine: &ExecutionEngine, committees: &CommitteeCache, active_balances: &ActiveBalanceCache, +) -> Result<()> { + apply_block_with_options( + state, + signed_block, + validate_result, + config, + engine, + committees, + active_balances, + block::BlockProcessingOptions::default(), + ) +} + +/// [`apply_block`] with explicit [`block::BlockProcessingOptions`]. +#[allow(clippy::too_many_arguments)] +pub fn apply_block_with_options( + state: &mut BeaconState, + signed_block: &containers::SignedBeaconBlock, + validate_result: bool, + config: &Config, + engine: &ExecutionEngine, + committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, + options: block::BlockProcessingOptions, ) -> Result<()> { // After `process_slots`, never before it. A block proposed at the first slot // of a fork's activation epoch is the *post*-fork shape while the state @@ -191,7 +241,7 @@ pub fn apply_block( engine, committees, active_balances, - block::BlockProcessingOptions::default(), + options, )?; // As in `process_slot`: the post-state's root, checked below and needed // again in the next slot, is then computed on the state's own nodes. diff --git a/crates/blockchain/state_transition/src/beacon/stf/operations.rs b/crates/blockchain/state_transition/src/beacon/stf/operations.rs index 5f9add7cc..7a12d1c8a 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/operations.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/operations.rs @@ -36,6 +36,7 @@ use crate::beacon::helpers::mutators::{ use crate::beacon::helpers::predicates::{ is_active_validator, is_slashable_attestation_data, is_slashable_validator, }; +use crate::beacon::helpers::target_balances::TargetBalanceDelta; use crate::beacon::lean_state_unreachable; use crate::beacon::preset; use crate::beacon::primitives::{Epoch, Gwei, HashTreeRoot as _, ValidatorIndex}; @@ -74,6 +75,7 @@ pub fn process_operations( config: &Config, committees: &CommitteeCache, active_balances: &ActiveBalanceCache, + target_delta: Option<&TargetBalanceDelta>, ) -> Result<()> { // Checked before anything below can mutate `state`, and unconditionally // rather than only when `attestations` is non-empty: a refused fork must @@ -139,6 +141,7 @@ pub fn process_operations( attestation, committees, active_balances, + target_delta, )?; } else { process_attestation(state, attestation, config, committees)?; diff --git a/crates/blockchain/state_transition/tests/beacon_spec/operations.rs b/crates/blockchain/state_transition/tests/beacon_spec/operations.rs index e627daeba..2e312def4 100644 --- a/crates/blockchain/state_transition/tests/beacon_spec/operations.rs +++ b/crates/blockchain/state_transition/tests/beacon_spec/operations.rs @@ -281,20 +281,38 @@ 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, &active_balances) + altair_stf::process_attestation( + state, + &attestation, + &committees, + &active_balances, + None, + ) } // 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, &active_balances) + deneb_stf::process_attestation( + state, + &attestation, + &committees, + &active_balances, + None, + ) } // 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, &active_balances) + electra_stf::process_attestation( + state, + &attestation, + &committees, + &active_balances, + None, + ) } // Gloas reshapes the container a third time (EIP-7688's unbounded // `AttestingIndices`) and modifies the function again (the new @@ -311,6 +329,7 @@ fn apply( meta.parent_slot, &committees, &active_balances, + None, ) } ForkName::Lean => lean_is_not_a_fixture_fork("attestation"), diff --git a/crates/net/rpc/src/beacon/gloas_proposal.rs b/crates/net/rpc/src/beacon/gloas_proposal.rs index 51b57d76e..a49ae83ff 100644 --- a/crates/net/rpc/src/beacon/gloas_proposal.rs +++ b/crates/net/rpc/src/beacon/gloas_proposal.rs @@ -433,7 +433,7 @@ struct Prepared { preferences: Option, /// What the block's state transition may leave unchecked: the reveal, when /// the caller set `skip_randao_verification`. - options: BlockProcessingOptions, + options: BlockProcessingOptions<'static>, } #[allow(clippy::too_many_arguments)] @@ -706,7 +706,10 @@ fn prepare( head_slot, parent_requests, preferences, - options: BlockProcessingOptions { randao }, + options: BlockProcessingOptions { + randao, + ..Default::default() + }, }) } diff --git a/crates/net/rpc/src/beacon/proposal.rs b/crates/net/rpc/src/beacon/proposal.rs index 8dcd6115d..28d86af48 100644 --- a/crates/net/rpc/src/beacon/proposal.rs +++ b/crates/net/rpc/src/beacon/proposal.rs @@ -433,7 +433,10 @@ async fn get_block( &own_version, slot, query.randao_reveal, - BlockProcessingOptions { randao }, + BlockProcessingOptions { + randao, + ..Default::default() + }, graffiti, ) .await; @@ -478,7 +481,7 @@ async fn produce( own_version: &ClientVersionV1, slot: Slot, randao_reveal: BlsSignature, - options: BlockProcessingOptions, + options: BlockProcessingOptions<'static>, graffiti: Bytes32, ) -> Result<(FuluBlockContents, String, ForkName), ApiError> { let config = store.config(); diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 85ad30c5a..c36e6bdae 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -11,6 +11,7 @@ mod state_codec; mod state_diff; mod state_writer; mod store; +mod target_balance_cache; pub use active_balance_cache::{ActiveBalanceCache, ActiveBalanceKey}; pub use api::{ @@ -21,6 +22,7 @@ pub use committee_cache::{CommitteeCache, Lookup, ShufflingKey}; /// callers can match on it (e.g. to distinguish [`Error::DbVersionMismatch`]). pub use error::Error; pub use liveness::ObservedLiveness; +pub use target_balance_cache::{TargetBalanceCache, TargetBalances}; // `CacheKey` lives in `state_writer` (beside the `StateCache` it keys), not // `store`; re-exported here so the public path (`ethlambda_storage::CacheKey`) // is unaffected by which module owns it. diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 14ac9116f..48b1dfefb 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -12,6 +12,7 @@ use crate::committee_cache::CommitteeCache; use crate::error::Error; use crate::liveness::ObservedLiveness; use crate::pools::{AttestationPool, OperationPool}; +use crate::target_balance_cache::TargetBalanceCache; use ethlambda_crypto::signature::ValidatorSignature; use ethlambda_types::{ @@ -1203,6 +1204,10 @@ pub struct Store { /// `beacon::helpers::accessors::ActiveBalanceCacheExt` for the /// state-aware half. Always empty on lean. active_balance_cache: Arc, + /// Each imported block's unslashed target balances, so the pulled-up tip + /// extends its parent's instead of rescanning the registry. See + /// [`TargetBalanceCache`]. Always empty on lean. + target_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` @@ -1847,6 +1852,7 @@ impl Store { committee_cache: Arc::new(CommitteeCache::default()), observed_liveness: Arc::new(ObservedLiveness::default()), active_balance_cache: Arc::new(ActiveBalanceCache::default()), + target_balance_cache: Arc::new(TargetBalanceCache::default()), beacon: Default::default(), state_writer, state_handoff: Default::default(), @@ -2178,6 +2184,10 @@ impl Store { } } Chain::Beacon => { + // An entry below the finalized epoch is never read again: + // a block consults its parent's only within one epoch. + self.target_balance_cache + .prune_below(finalized.slot / SLOTS_PER_EPOCH); // `finalized.slot` is the finalized *epoch's* start slot // (`Store::beacon_checkpoint_as_stored`), which can name a // slot nobody ever built a block for. Pruning to it @@ -3301,6 +3311,13 @@ impl Store { Arc::clone(&self.active_balance_cache) } + /// The per-block target-balance cache shared by every clone of this store. + /// + /// An owned `Arc` for the same reason as [`Self::committee_cache`]. + pub fn target_balance_cache(&self) -> Arc { + Arc::clone(&self.target_balance_cache) + } + /// Returns whether a state is available for the given block root. /// /// True if the state cache or `pending_states` holds the state, a snapshot diff --git a/crates/storage/src/target_balance_cache.rs b/crates/storage/src/target_balance_cache.rs new file mode 100644 index 000000000..e768ba9a0 --- /dev/null +++ b/crates/storage/src/target_balance_cache.rs @@ -0,0 +1,149 @@ +//! Per-block unslashed TIMELY_TARGET balances, kept so a block's pulled-up tip +//! does not rescan the validator registry. +//! +//! Fork choice's `compute_pulled_up_tip` needs, for every imported block, the +//! balance that cast a timely target vote in the previous and current epoch of +//! the block's post-state. Summing it is one pass over every validator, which +//! is the dominant per-block cost at mainnet scale. Within one epoch the sum +//! only grows by what a block adds (effective balances change only at the +//! epoch boundary), so the child's totals are its parent's plus the block's +//! own delta. This is Lighthouse's `ProgressiveBalancesCache`; it cannot live +//! on the state here (the per-fork state structs derive SSZ), so it is held by +//! the `Store` beside [`crate::ActiveBalanceCache`], keyed by block root. +//! +//! A block root identifies the post-state it produced, so an entry is never +//! stale: there is nothing to invalidate, only to bound. This module knows +//! nothing about a `BeaconState`; `ethlambda-state-transition`'s +//! `beacon::helpers::target_balances` derives the values and decides when a +//! parent's entry may be extended. +//! +//! # Bound +//! +//! An entry is a root plus three words. [`TARGET_BALANCE_CACHE_CAPACITY`] +//! caps the table; an insert into a full table drops the entry with the lowest +//! epoch. [`TargetBalanceCache::prune_below`] additionally drops every entry +//! older than the finalized epoch, which no later import can read: a block's +//! parent is only consulted when it shares the block's epoch. A dropped entry +//! costs one full registry scan on the next import that needed it, never a +//! wrong answer. + +use std::collections::HashMap; +use std::sync::Mutex; + +use ethlambda_types::beacon::primitives::{Epoch, Gwei, Root}; + +/// How many block roots stay resident. +/// +/// Sized for several epochs of the unfinalized window across forks; the cost of +/// exceeding it is a rescan, so it only has to keep a long-lived store from +/// growing the table without limit while finality stalls. +const TARGET_BALANCE_CACHE_CAPACITY: usize = 4096; + +/// The unslashed balance with the TIMELY_TARGET flag set, for the previous and +/// current epoch of one block's post-state. +/// +/// Raw sums: the consumer applies the one-increment floor the specification's +/// `get_total_balance` does, which is not additive and so cannot be stored. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TargetBalances { + /// The current epoch of the post-state the sums belong to. + pub epoch: Epoch, + /// Previous epoch's unslashed participating balance. + pub previous: Gwei, + /// Current epoch's unslashed participating balance. + pub current: Gwei, +} + +/// Target balances by block root. Internally synchronized, so every method +/// takes `&self`; the lock is never held while anything is computed. +#[derive(Debug, Default)] +pub struct TargetBalanceCache { + entries: Mutex>, +} + +impl TargetBalanceCache { + /// The resident totals for `root`'s post-state, if any. + pub fn get(&self, root: &Root) -> Option { + self.entries.lock().unwrap().get(root).copied() + } + + /// Records `root`'s totals, evicting the lowest-epoch entry when full. + pub fn insert(&self, root: Root, totals: TargetBalances) { + let mut entries = self.entries.lock().unwrap(); + if entries.len() >= TARGET_BALANCE_CACHE_CAPACITY && !entries.contains_key(&root) { + let victim = entries + .iter() + .min_by_key(|(_, entry)| entry.epoch) + .map(|(root, _)| *root); + if let Some(victim) = victim { + entries.remove(&victim); + } + } + entries.insert(root, totals); + } + + /// Drops every entry whose epoch is below `epoch`; called at finalization. + pub fn prune_below(&self, epoch: Epoch) { + self.entries + .lock() + .unwrap() + .retain(|_, entry| entry.epoch >= epoch); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn totals(epoch: Epoch) -> TargetBalances { + TargetBalances { + epoch, + previous: 1, + current: 2, + } + } + + #[test] + fn an_inserted_entry_is_returned() { + let cache = TargetBalanceCache::default(); + let root = Root::repeat_byte(1); + assert_eq!(cache.get(&root), None); + cache.insert(root, totals(5)); + assert_eq!(cache.get(&root), Some(totals(5))); + } + + #[test] + fn a_full_cache_evicts_the_lowest_epoch() { + let cache = TargetBalanceCache::default(); + let mut roots = Vec::new(); + for i in 0..TARGET_BALANCE_CACHE_CAPACITY { + let mut root = Root::ZERO; + root.0[..8].copy_from_slice(&(i as u64).to_be_bytes()); + roots.push(root); + // Epochs fall as insertions rise, so insertion order and epoch + // disagree about which entry is oldest. + cache.insert(root, totals((TARGET_BALANCE_CACHE_CAPACITY - i) as Epoch)); + } + let newest = Root::repeat_byte(0xff); + cache.insert(newest, totals(10_000)); + + assert_eq!( + cache.entries.lock().unwrap().len(), + TARGET_BALANCE_CACHE_CAPACITY + ); + assert_eq!(cache.get(&newest), Some(totals(10_000))); + let last = roots.last().unwrap(); + assert_eq!(cache.get(last), None, "the lowest epoch is the victim"); + assert!(cache.get(&roots[0]).is_some()); + } + + #[test] + fn prune_below_keeps_the_boundary_epoch() { + let cache = TargetBalanceCache::default(); + cache.insert(Root::repeat_byte(1), totals(3)); + cache.insert(Root::repeat_byte(2), totals(4)); + cache.prune_below(4); + assert_eq!(cache.get(&Root::repeat_byte(1)), None); + assert_eq!(cache.get(&Root::repeat_byte(2)), Some(totals(4))); + } +} From fd7271028004bd6cbf29987a982bd330f01e7921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:51:14 -0300 Subject: [PATCH 2/2] perf(beacon): read each attester's validator once for the target-balance delta The progressive target-balance delta recorded every attester that newly earned TIMELY_TARGET with `state.validator(index)`, a second registry tree descent per attester on top of the one the reward computation had already made. Offline import replay at mainnet scale put the `attestations` step at 5.8 ms per block against 2.8 ms before the tree-backed lists and the delta; this is the delta's share of that. The read phase now keeps the effective balance and `slashed` flag it read for the reward, and `TargetBalanceDelta::record_new_target` takes those two values instead of a `&Validator`. Nothing in the read phase writes the registry, so the cached values are the ones a second read would return, and a newly earned target flag implies the loop reached that flag and read the attester first. Applies to the altair, deneb, electra (electra and fulu) and gloas versions of `process_attestation`. --- .../src/beacon/helpers/target_balances.rs | 40 ++++++++++--------- .../state_transition/src/beacon/stf/altair.rs | 27 ++++++++----- .../state_transition/src/beacon/stf/deneb.rs | 27 ++++++++----- .../src/beacon/stf/electra.rs | 27 ++++++++----- .../state_transition/src/beacon/stf/gloas.rs | 29 +++++++++----- 5 files changed, 90 insertions(+), 60 deletions(-) diff --git a/crates/blockchain/state_transition/src/beacon/helpers/target_balances.rs b/crates/blockchain/state_transition/src/beacon/helpers/target_balances.rs index 5ec62438b..e2eefbb7b 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/target_balances.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/target_balances.rs @@ -48,7 +48,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use ethlambda_storage::{TargetBalanceCache, TargetBalances}; use crate::beacon::containers::BeaconState; -use crate::beacon::containers::{SignedBeaconBlock, Validator}; +use crate::beacon::containers::SignedBeaconBlock; use crate::beacon::error::Result; use crate::beacon::primitives::{Epoch, Gwei, Root}; @@ -77,11 +77,21 @@ impl PartialEq for TargetBalanceDelta { impl Eq for TargetBalanceDelta {} impl TargetBalanceDelta { - /// Records that `validator` just earned TIMELY_TARGET in the current or - /// previous epoch's participation. A slashed validator is not counted, as - /// the specification's unslashed participating set excludes it. - pub fn record_new_target(&self, current_epoch_target: bool, validator: &Validator) { - if validator.slashed { + /// Records that a validator with `effective_balance` just earned + /// TIMELY_TARGET in the current or previous epoch's participation. A + /// `slashed` validator is not counted, as the specification's unslashed + /// participating set excludes it. + /// + /// Takes the two fields rather than the validator, so the caller, which + /// has already read the validator for the reward, does not descend the + /// registry a second time. + pub fn record_new_target( + &self, + current_epoch_target: bool, + effective_balance: Gwei, + slashed: bool, + ) { + if slashed { return; } let side = if current_epoch_target { @@ -91,7 +101,7 @@ impl TargetBalanceDelta { }; let total = side .load(Ordering::Relaxed) - .saturating_add(validator.effective_balance); + .saturating_add(effective_balance); side.store(total, Ordering::Relaxed); } @@ -191,20 +201,12 @@ pub fn block_has_slashings(block: &SignedBeaconBlock) -> bool { mod tests { use super::*; - fn validator(effective_balance: Gwei, slashed: bool) -> Validator { - Validator { - effective_balance, - slashed, - ..Default::default() - } - } - #[test] fn a_delta_skips_slashed_validators_and_splits_by_epoch() { let delta = TargetBalanceDelta::default(); - delta.record_new_target(false, &validator(32, false)); - delta.record_new_target(true, &validator(16, false)); - delta.record_new_target(true, &validator(64, true)); + delta.record_new_target(false, 32, false); + delta.record_new_target(true, 16, false); + delta.record_new_target(true, 64, true); assert_eq!((delta.previous(), delta.current()), (32, 16)); } @@ -219,7 +221,7 @@ mod tests { }; cache.insert(parent, totals); let delta = TargetBalanceDelta::default(); - delta.record_new_target(true, &validator(7, false)); + delta.record_new_target(true, 7, false); let extended = extend_parent_totals(&cache, &parent, 5, &delta).unwrap(); assert_eq!((extended.previous, extended.current), (100, 17)); diff --git a/crates/blockchain/state_transition/src/beacon/stf/altair.rs b/crates/blockchain/state_transition/src/beacon/stf/altair.rs index 0cc5ab80c..ed216ff51 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/altair.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/altair.rs @@ -181,9 +181,11 @@ 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; + // it newly earns: each read is a tree descent, and neither the + // effective balance nor `slashed` can change inside this read-only + // phase. The target-balance delta below needs the same two values, so + // both are kept rather than read again. + let mut attester: Option<(Gwei, bool)> = None; for &flag_index in &participation_flag_indices { if has_flag(current_flags, flag_index) { continue; @@ -195,15 +197,16 @@ 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 = match attester_increments { - Some(increments) => increments, + let (effective_balance, _) = match attester { + Some(attester) => attester, None => { - let increments = state.validator(index)?.effective_balance - / preset::EFFECTIVE_BALANCE_INCREMENT; - attester_increments = Some(increments); - increments + let validator = state.validator(index)?; + let read = (validator.effective_balance, validator.slashed); + attester = Some(read); + read } }; + let increments = effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT; let reward = (increments * base_reward_per_increment) .checked_mul(weight) .ok_or(Error::ArithmeticOverflow( @@ -216,7 +219,11 @@ pub fn process_attestation( if let Some(delta) = target_delta && has_flag(new_flags, constants::TIMELY_TARGET_FLAG_INDEX) { - delta.record_new_target(current_epoch_target, state.validator(index)?); + // Earning the target flag means the loop above reached that flag + // and so read the attester. + let (effective_balance, slashed) = + attester.expect("a newly earned flag read the attester"); + delta.record_new_target(current_epoch_target, effective_balance, slashed); } if new_flags != 0 { updates.push((index, new_flags)); diff --git a/crates/blockchain/state_transition/src/beacon/stf/deneb.rs b/crates/blockchain/state_transition/src/beacon/stf/deneb.rs index 18a4dcb5a..77cb69cc1 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/deneb.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/deneb.rs @@ -389,9 +389,11 @@ 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; + // it newly earns: each read is a tree descent, and neither the + // effective balance nor `slashed` can change inside this read-only + // phase. The target-balance delta below needs the same two values, so + // both are kept rather than read again. + let mut attester: Option<(Gwei, bool)> = None; for &flag_index in &participation_flag_indices { if has_flag(current_flags, flag_index) { continue; @@ -403,15 +405,16 @@ 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 = match attester_increments { - Some(increments) => increments, + let (effective_balance, _) = match attester { + Some(attester) => attester, None => { - let increments = state.validator(index)?.effective_balance - / preset::EFFECTIVE_BALANCE_INCREMENT; - attester_increments = Some(increments); - increments + let validator = state.validator(index)?; + let read = (validator.effective_balance, validator.slashed); + attester = Some(read); + read } }; + let increments = effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT; let reward = (increments * base_reward_per_increment) .checked_mul(weight) .ok_or(Error::ArithmeticOverflow( @@ -424,7 +427,11 @@ pub fn process_attestation( if let Some(delta) = target_delta && has_flag(new_flags, constants::TIMELY_TARGET_FLAG_INDEX) { - delta.record_new_target(current_epoch_target, state.validator(index)?); + // Earning the target flag means the loop above reached that flag + // and so read the attester. + let (effective_balance, slashed) = + attester.expect("a newly earned flag read the attester"); + delta.record_new_target(current_epoch_target, effective_balance, slashed); } if new_flags != 0 { updates.push((index, new_flags)); diff --git a/crates/blockchain/state_transition/src/beacon/stf/electra.rs b/crates/blockchain/state_transition/src/beacon/stf/electra.rs index e586e4850..8f875aa0f 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/electra.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/electra.rs @@ -764,9 +764,11 @@ 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; + // it newly earns: each read is a tree descent, and neither the + // effective balance nor `slashed` can change inside this read-only + // phase. The target-balance delta below needs the same two values, so + // both are kept rather than read again. + let mut attester: Option<(Gwei, bool)> = None; for &flag_index in &participation_flag_indices { if has_flag(current_flags, flag_index) { continue; @@ -776,15 +778,16 @@ 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 = match attester_increments { - Some(increments) => increments, + let (effective_balance, _) = match attester { + Some(attester) => attester, None => { - let increments = state.validator(index)?.effective_balance - / preset::EFFECTIVE_BALANCE_INCREMENT; - attester_increments = Some(increments); - increments + let validator = state.validator(index)?; + let read = (validator.effective_balance, validator.slashed); + attester = Some(read); + read } }; + let increments = effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT; let reward = (increments * base_reward_per_increment) .checked_mul(weight) .ok_or(Error::ArithmeticOverflow( @@ -797,7 +800,11 @@ pub fn process_attestation( if let Some(delta) = target_delta && has_flag(new_flags, constants::TIMELY_TARGET_FLAG_INDEX) { - delta.record_new_target(current_epoch_target, state.validator(index)?); + // Earning the target flag means the loop above reached that flag + // and so read the attester. + let (effective_balance, slashed) = + attester.expect("a newly earned flag read the attester"); + delta.record_new_target(current_epoch_target, effective_balance, slashed); } if new_flags != 0 { updates.push((index, new_flags)); diff --git a/crates/blockchain/state_transition/src/beacon/stf/gloas.rs b/crates/blockchain/state_transition/src/beacon/stf/gloas.rs index 12f99811c..5a42493bd 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/gloas.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/gloas.rs @@ -1441,9 +1441,11 @@ 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; + // flag it newly earns: each read is a tree descent, and neither + // the effective balance nor `slashed` can change inside this + // read-only phase. The target-balance delta below needs the same + // two values, so both are kept rather than read again. + let mut attester: Option<(Gwei, bool)> = None; let mut new_flags: ParticipationFlags = 0; for &flag_index in &participation_flag_indices { if has_flag(current_flags, flag_index) { @@ -1451,12 +1453,13 @@ pub fn process_attestation( } new_flags = add_flag(new_flags, flag_index); let weight = constants::PARTICIPATION_FLAG_WEIGHTS[flag_index]; - let effective_balance = match attester_effective_balance { - Some(effective_balance) => effective_balance, + let (effective_balance, _) = match attester { + Some(attester) => attester, None => { - let effective_balance = state.validator(index)?.effective_balance; - attester_effective_balance = Some(effective_balance); - effective_balance + let validator = state.validator(index)?; + let read = (validator.effective_balance, validator.slashed); + attester = Some(read); + read } }; let increments = effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT; @@ -1475,7 +1478,11 @@ pub fn process_attestation( if let Some(delta) = target_delta && has_flag(new_flags, constants::TIMELY_TARGET_FLAG_INDEX) { - delta.record_new_target(current_epoch_target, state.validator(index)?); + // Earning the target flag means the loop above reached that + // flag and so read the attester. + let (effective_balance, slashed) = + attester.expect("a newly earned flag read the attester"); + delta.record_new_target(current_epoch_target, effective_balance, slashed); } let will_set_new_flag = new_flags != 0; @@ -1485,8 +1492,8 @@ pub fn process_attestation( && payment.withdrawal.amount > 0 { // `will_set_new_flag` means the loop above read it already. - let effective_balance = match attester_effective_balance { - Some(effective_balance) => effective_balance, + let effective_balance = match attester { + Some((effective_balance, _)) => effective_balance, None => state.validator(index)?.effective_balance, }; payment.weight = payment.weight.checked_add(effective_balance).ok_or(