From 795a6efe1de79f820db7dac65372b42da3e7471b 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, 29 Sep 2026 12:00:32 -0300 Subject: [PATCH 1/4] perf(beacon): run epoch steps 1-3 over one registry scan Justification, inactivity updates and rewards read the tree-backed registry through ~19 full scans and ~21 validator(i) descents per active validator, and the per-block pulled-up tip repeated step 1 on every imported block. helpers::participation walks validators().iter() once, zipped with the flat participation slices, into per-validator flags, effective balances and the balance totals (EpochSummary). Steps 1-3 then run over flat data: scores are updated in place, and only balances that changed are written back, per component in spec order. Justification (and so the pulled-up tip) needs only ParticipationTotals, one scan with no allocation. The public step functions and the flag/inactivity delta helpers keep their signatures for the fixtures and become thin wrappers. Effective balance updates zip validators with balances, since balances()[i] is now a tree descent for most validators. The specification-shaped code stays as a reference oracle for tests and debug builds: the driver runs it on a clone (registries up to 4096) and asserts balances, scores, justification bits and checkpoints agree; randomized tests compare every value and error across forks. --- .../src/beacon/helpers/altair.rs | 153 ++--- .../src/beacon/helpers/mod.rs | 3 + .../src/beacon/helpers/participation.rs | 408 +++++++++++++ .../beacon/helpers/participation_reference.rs | 170 ++++++ .../src/beacon/stf/epoch/altair.rs | 250 +++++--- .../src/beacon/stf/epoch/altair_reference.rs | 141 +++++ .../src/beacon/stf/epoch/capella.rs | 4 +- .../src/beacon/stf/epoch/electra.rs | 14 +- .../src/beacon/stf/epoch/mod.rs | 14 +- .../stf/epoch/participation_equivalence.rs | 548 ++++++++++++++++++ docs/beacon_stf.md | 13 + 11 files changed, 1537 insertions(+), 181 deletions(-) create mode 100644 crates/blockchain/state_transition/src/beacon/helpers/participation.rs create mode 100644 crates/blockchain/state_transition/src/beacon/helpers/participation_reference.rs create mode 100644 crates/blockchain/state_transition/src/beacon/stf/epoch/altair_reference.rs create mode 100644 crates/blockchain/state_transition/src/beacon/stf/epoch/participation_equivalence.rs diff --git a/crates/blockchain/state_transition/src/beacon/helpers/altair.rs b/crates/blockchain/state_transition/src/beacon/helpers/altair.rs index eaa008c2e..f18bedf00 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/altair.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/altair.rs @@ -31,7 +31,8 @@ //! without changing the shape of any SSZ container. That is the only function //! below that takes a [`Config`]; the rest need nothing a network could vary. -use super::finality::{get_eligible_validator_indices, is_in_inactivity_leak}; +use super::participation::{EpochSummary, RewardContext}; +use super::predicates::is_active_validator; use crate::beacon::bls; use crate::beacon::config::Config; use crate::beacon::constants; @@ -45,7 +46,7 @@ use crate::beacon::primitives::{Epoch, Gwei, ParticipationFlags, ValidatorIndex} use super::accessors::{ get_active_validator_indices, get_block_root, get_block_root_at_slot, get_current_epoch, - get_previous_epoch, get_seed, get_total_active_balance, get_total_balance, + get_previous_epoch, get_seed, get_total_active_balance, }; use super::math::integer_squareroot; use super::shuffling::compute_shuffled_index; @@ -212,10 +213,8 @@ pub fn get_base_reward(state: &BeaconState, index: ValidatorIndex) -> Result Result<(Vec, Vec)> { - let validator_count = state.validators().len(); - let mut rewards = vec![0; validator_count]; - let mut penalties = vec![0; validator_count]; - - let previous_epoch = get_previous_epoch(state); - let unslashed_participating_indices = - get_unslashed_participating_indices(state, flag_index, previous_epoch)?; - let weight = constants::PARTICIPATION_FLAG_WEIGHTS[flag_index]; - let unslashed_participating_balance = - get_total_balance(state, &unslashed_participating_indices)?; - let unslashed_participating_increments = - unslashed_participating_balance / preset::EFFECTIVE_BALANCE_INCREMENT; - let active_increments = get_total_active_balance(state)? / preset::EFFECTIVE_BALANCE_INCREMENT; - - // Hoisted out of the loop below, where the specification writes - // `get_base_reward(state, index)` per eligible validator. That helper is - // `increments * get_base_reward_per_increment(state)`, and the second - // factor is `get_total_active_balance`, an unconditional `O(registry - // size)` scan with no cache of its own. That is the same quantity - // `active_increments` above already paid for, just run through a - // different formula (`get_base_reward_per_increment` divides by - // `integer_squareroot`, `active_increments` does not), so it is not - // reusable as-is and has to be hoisted on its own. - // - // [`process_epoch::electra::process_epoch`] calls this (via - // `process_epoch::altair::process_rewards_and_penalties`) once per - // [`crate::beacon::constants::PARTICIPATION_FLAG_WEIGHTS`] entry, three times per - // epoch boundary. At mainnet's ~1M validators, the unhoisted form is - // three separate million-element scans per *eligible validator*, effectively - // unbounded, for what this function already computes once above. This is - // the same bug already fixed in `process_attestation`'s per-attester loop - // (see that function's own comment), left unfixed here because it runs - // once per epoch rather than once per block and so never showed up in a - // profile that did not cross an epoch boundary. - // - // Measured directly: `tests::measures_the_cost_of_get_flag_index_deltas` - // times this call at 2^15 validators. Unhoisted, that call took ~11.9s; - // hoisted, ~384us: roughly 31,000x at that scale, and the gap widens - // further at mainnet's ~2^20 validators, since the unhoisted form is - // O(n^2) (`1024x` slower again at that size) while this is O(n) (`32x` - // slower again, same as every other size-dependent cost in this crate). - let base_reward_per_increment = get_base_reward_per_increment(state)?; - - for index in get_eligible_validator_indices(state) { - // `get_base_reward(state, index)` inlined against the hoisted - // per-increment value, in 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 base_reward = increments * base_reward_per_increment; - if unslashed_participating_indices - .binary_search(&index) - .is_ok() - { - if !is_in_inactivity_leak(state) { - let reward_numerator = base_reward * weight * unslashed_participating_increments; - rewards[index as usize] += - reward_numerator / (active_increments * constants::WEIGHT_DENOMINATOR); - } - } else if flag_index != constants::TIMELY_HEAD_FLAG_INDEX { - penalties[index as usize] += base_reward * weight / constants::WEIGHT_DENOMINATOR; - } + // Thin projection of the one-pass summary: the fixtures call this one flag + // at a time, so each call pays its own scan, which the epoch driver does + // not (it builds a single summary for all steps). + let summary = EpochSummary::build(state, false)?; + let context = RewardContext::new(state, summary.totals()); + + let mut rewards = vec![0; summary.len()]; + let mut penalties = vec![0; summary.len()]; + for (index, (flags, effective_balance)) in summary.iter().enumerate() { + let (reward, penalty) = context.flag_deltas(flags, effective_balance)[flag_index]; + rewards[index] = reward; + penalties[index] = penalty; } Ok((rewards, penalties)) } @@ -410,39 +363,27 @@ pub fn get_inactivity_penalty_deltas( state: &BeaconState, config: &Config, ) -> Result<(Vec, Vec)> { - let validator_count = state.validators().len(); - let rewards = vec![0; validator_count]; - let mut penalties = vec![0; validator_count]; - - let previous_epoch = get_previous_epoch(state); - let matching_target_indices = get_unslashed_participating_indices( - state, - constants::TIMELY_TARGET_FLAG_INDEX, - previous_epoch, - )?; - + let summary = EpochSummary::build(state, false)?; + let context = RewardContext::new(state, summary.totals()); let (_, _, inactivity_scores) = state.altair_validator_lists()?; - for index in get_eligible_validator_indices(state) { - if matching_target_indices.binary_search(&index).is_err() { - let effective_balance = state.validator(index)?.effective_balance; - let inactivity_score = + let rewards = vec![0; summary.len()]; + let mut penalties = vec![0; summary.len()]; + for (index, (flags, effective_balance)) in summary.iter().enumerate() { + penalties[index] = context.inactivity_penalty( + flags, + effective_balance, + || { inactivity_scores - .get(index as usize) + .get(index) .copied() .ok_or(Error::IndexOutOfBounds { - index: index as usize, + index, len: inactivity_scores.len(), - })?; - - let penalty_numerator = effective_balance.checked_mul(inactivity_score).ok_or( - Error::ArithmeticOverflow("effective_balance * inactivity_scores[index]"), - )?; - let inactivity_penalty_quotient = - preset::retuned::inactivity_penalty_quotient(state.fork_name()); - let penalty_denominator = config.inactivity_score_bias * inactivity_penalty_quotient; - penalties[index as usize] += penalty_numerator / penalty_denominator; - } + }) + }, + config, + )?; } Ok((rewards, penalties)) diff --git a/crates/blockchain/state_transition/src/beacon/helpers/mod.rs b/crates/blockchain/state_transition/src/beacon/helpers/mod.rs index 3449b26aa..cf5b91b36 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/mod.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/mod.rs @@ -21,6 +21,9 @@ pub mod fulu; pub mod math; pub mod misc; pub mod mutators; +pub mod participation; +#[cfg(any(test, debug_assertions))] +pub mod participation_reference; pub mod predicates; pub mod shuffling; #[cfg(any(test, feature = "test-utils"))] diff --git a/crates/blockchain/state_transition/src/beacon/helpers/participation.rs b/crates/blockchain/state_transition/src/beacon/helpers/participation.rs new file mode 100644 index 000000000..894b96d32 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/participation.rs @@ -0,0 +1,408 @@ +//! One registry pass for the epoch's participation accounting. +//! +//! Justification, inactivity updates and rewards each need to know, per +//! validator, whether it earned each timeliness flag last epoch and whether it +//! is eligible for rewards, plus a few balance totals over those sets. Asking +//! the specification's helpers for that one question at a time reads the +//! tree-backed registry many times over and, per active validator, once more +//! by index (a tree descent each). Here the registry is walked once, with +//! `validators().iter()` zipped against the participation slices, and every +//! later step runs over flat data. +//! +//! The pieces, from cheapest to most complete: +//! +//! - [`ParticipationTotals::compute`]: only the balance totals justification +//! divides by. One scan, no allocation. This is what the per-block +//! pulled-up tip pays. +//! - [`EpochSummary::build`]: the same scan, additionally keeping each +//! validator's [`EpochFlags`] and effective balance. +//! - [`RewardContext`]: the epoch-wide constants of the reward formulas. +//! Built after justification, since the inactivity-leak flag reads the +//! finalized checkpoint which justification may move. +//! [`RewardContext::deltas`] maps one validator's flags, effective balance +//! and inactivity score to its [`ValidatorDeltas`]. +//! +//! A summary is only valid while nothing it read changes: steps 1-3 write the +//! justification fields, `inactivity_scores` and `balances`, and none of those +//! feed a flag or an effective balance. It must be dropped before registry +//! updates run, and is never stored in the state. +//! +//! The specification-shaped implementation these replace is kept, compiled for +//! tests and debug builds only, in [`super::participation_reference`]. + +use crate::beacon::config::Config; +use crate::beacon::constants; +use crate::beacon::containers::BeaconState; +use crate::beacon::error::{Error, Result}; +use crate::beacon::fork::ForkName; +use crate::beacon::preset; +use crate::beacon::primitives::Gwei; + +use super::accessors::{get_current_epoch, get_previous_epoch}; +use super::altair::has_flag; +use super::math::integer_squareroot; +use super::predicates::is_active_validator; + +/// Number of timeliness flags a validator can earn. +const FLAG_COUNT: usize = constants::PARTICIPATION_FLAG_WEIGHTS.len(); + +/// A validator's standing for the previous epoch's accounting. +/// +/// Bits 0..3 (one per timeliness flag): the validator is in +/// `get_unslashed_participating_indices(state, flag, previous_epoch)`, i.e. +/// active in the previous epoch, unslashed, and has the flag set. +/// Bit 3: the validator is in `get_eligible_validator_indices(state)`. +#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)] +pub struct EpochFlags(u8); + +impl EpochFlags { + const ELIGIBLE_BIT: u8 = 1 << FLAG_COUNT; + + /// Whether the validator is an unslashed participant for `flag_index` + /// (one of the `TIMELY_*_FLAG_INDEX` constants) in the previous epoch. + pub fn participated(self, flag_index: usize) -> bool { + debug_assert!(flag_index < FLAG_COUNT); + self.0 & (1 << flag_index) != 0 + } + + /// Whether the validator is eligible for this epoch's rewards and + /// penalties. + pub fn is_eligible(self) -> bool { + self.0 & Self::ELIGIBLE_BIT != 0 + } +} + +/// The balance totals justification and the reward formulas divide by. +/// +/// Each is `get_total_balance` of its set: a saturating sum of effective +/// balances, floored at one increment so a caller can divide by it. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct ParticipationTotals { + /// `get_total_active_balance`: the current epoch's active validators, + /// slashed ones included. + pub total_active_balance: Gwei, + /// Per flag, the unslashed participating balance of the previous epoch. + pub previous_epoch_flags: [Gwei; FLAG_COUNT], + /// The unslashed balance that cast a timely target vote in the current + /// epoch. Only meaningful when built with the current target requested + /// (see [`EpochSummary::build`]); otherwise just the floor. + pub current_epoch_target: Gwei, +} + +impl ParticipationTotals { + /// The totals justification needs, in one scan with no allocation. + /// + /// Fails exactly where `get_unslashed_participating_indices` does for + /// either epoch: [`Error::IndexOutOfBounds`] when a participation list is + /// shorter than an active validator's index (the previous epoch's list is + /// reported first). + pub fn compute(state: &BeaconState) -> Result { + scan(state, true, |_, _, _| {}) + } +} + +/// Walks the registry once, calling `each(index, flags, effective_balance)` per +/// validator, and returns the totals. +/// +/// The only place the membership predicates live. `current_target` says +/// whether the current epoch's list is read at all: the specification's +/// steps 2 and 3 never touch it, so a caller that only runs them must not be +/// failed by a short current list. +/// +/// 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( + state: &BeaconState, + current_target: bool, + mut each: impl FnMut(usize, EpochFlags, Gwei), +) -> Result { + let (previous_list, current_list, _) = state.altair_validator_lists()?; + let current_epoch = get_current_epoch(state); + let previous_epoch = get_previous_epoch(state); + let previous_list = if current_epoch == previous_epoch { + current_list + } else { + previous_list + }; + + let mut total_active: Gwei = 0; + let mut previous_flags = [0 as Gwei; FLAG_COUNT]; + let mut current_target_balance: Gwei = 0; + // The two lists are checked independently and the previous epoch's error + // wins, matching the order the specification's step 1 asks for them in. + let mut previous_error = None; + let mut current_error = None; + + for (index, validator) in state.validators().iter().enumerate() { + let effective_balance = validator.effective_balance; + let active_now = is_active_validator(validator, current_epoch); + let active_before = is_active_validator(validator, previous_epoch); + + if active_now { + total_active = total_active.saturating_add(effective_balance); + } + + let mut bits = 0u8; + if active_before { + match previous_list.get(index) { + Some(&participation) => { + if !validator.slashed { + for (flag_index, total) in previous_flags.iter_mut().enumerate() { + if has_flag(participation, flag_index) { + bits |= 1 << flag_index; + *total = total.saturating_add(effective_balance); + } + } + } + } + None => { + previous_error.get_or_insert(Error::IndexOutOfBounds { + index, + len: previous_list.len(), + }); + } + } + } + // Slashed but not yet withdrawable validators stay eligible so they + // keep paying penalties after leaving the active set. + if active_before || (validator.slashed && previous_epoch + 1 < validator.withdrawable_epoch) + { + bits |= EpochFlags::ELIGIBLE_BIT; + } + + if current_target && active_now { + match current_list.get(index) { + Some(&participation) => { + if has_flag(participation, constants::TIMELY_TARGET_FLAG_INDEX) + && !validator.slashed + { + current_target_balance = + current_target_balance.saturating_add(effective_balance); + } + } + None => { + current_error.get_or_insert(Error::IndexOutOfBounds { + index, + len: current_list.len(), + }); + } + } + } + + each(index, EpochFlags(bits), effective_balance); + } + + if let Some(error) = previous_error.or(current_error) { + 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), + }) +} + +/// Per-validator flags and effective balances for one epoch boundary, plus the +/// totals from the same scan. +/// +/// Built once before step 1 and dropped after step 3; see the module docs for +/// why it must not outlive them. Costs one byte and eight bytes per registry +/// entry. +pub struct EpochSummary { + flags: Vec, + effective_balances: Vec, + totals: ParticipationTotals, +} + +impl EpochSummary { + /// Scans the registry once. + /// + /// `need_current_target` is whether the caller will run justification: + /// only then is the current epoch's participation list read, and only + /// then is [`ParticipationTotals::current_epoch_target`] meaningful. Fails + /// like [`ParticipationTotals::compute`]. + pub fn build(state: &BeaconState, need_current_target: bool) -> Result { + let count = state.validators().len(); + let mut flags = Vec::with_capacity(count); + let mut effective_balances = Vec::with_capacity(count); + let totals = scan(state, need_current_target, |_, validator_flags, balance| { + flags.push(validator_flags); + effective_balances.push(balance); + })?; + Ok(Self { + flags, + effective_balances, + totals, + }) + } + + /// The totals from the scan. + pub fn totals(&self) -> &ParticipationTotals { + &self.totals + } + + /// The number of validators the summary covers (the registry length at + /// build time). + pub fn len(&self) -> usize { + self.flags.len() + } + + /// Whether the summary covers no validators. + pub fn is_empty(&self) -> bool { + self.flags.is_empty() + } + + /// Validator `index`'s flags and effective balance, or `None` past the + /// end. + pub fn get(&self, index: usize) -> Option<(EpochFlags, Gwei)> { + Some((*self.flags.get(index)?, self.effective_balances[index])) + } + + /// Every validator's flags and effective balance, in registry order. + pub fn iter(&self) -> impl ExactSizeIterator + '_ { + self.flags + .iter() + .copied() + .zip(self.effective_balances.iter().copied()) + } +} + +/// One validator's rewards and penalties for the epoch, as `(reward, penalty)` +/// pairs in the specification's order: source, target, head, inactivity. +#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)] +pub struct ValidatorDeltas(pub [(Gwei, Gwei); FLAG_COUNT + 1]); + +impl ValidatorDeltas { + /// Position of the inactivity component in the array. + pub const INACTIVITY: usize = FLAG_COUNT; + + /// `balance` after applying each component in the specification's order, + /// reward first and then penalty. + /// + /// Deliberately not the net of all rewards and penalties: the balance + /// floors at zero after each penalty, so a reward arriving after a + /// penalty that emptied the balance still counts. + pub fn apply(self, balance: Gwei) -> Gwei { + self.0.iter().fold(balance, |balance, &(reward, penalty)| { + balance.saturating_add(reward).saturating_sub(penalty) + }) + } +} + +/// The epoch-wide constants of the altair reward formulas. +/// +/// Build it after justification: the leak flag reads `finalized_checkpoint`, +/// which justification may advance. +#[derive(Clone, Copy, Debug)] +pub struct RewardContext { + leaking: bool, + fork: ForkName, + base_reward_per_increment: Gwei, + active_increments: Gwei, + participating_increments: [Gwei; FLAG_COUNT], +} + +impl RewardContext { + /// Derives the constants from `totals` (taken before or after + /// justification alike) and the state's finality, which must be the + /// post-justification one. + pub fn new(state: &BeaconState, totals: &ParticipationTotals) -> Self { + Self { + leaking: super::finality::is_in_inactivity_leak(state), + fork: state.fork_name(), + base_reward_per_increment: preset::EFFECTIVE_BALANCE_INCREMENT + * preset::BASE_REWARD_FACTOR + / integer_squareroot(totals.total_active_balance), + active_increments: totals.total_active_balance / preset::EFFECTIVE_BALANCE_INCREMENT, + participating_increments: totals + .previous_epoch_flags + .map(|total| total / preset::EFFECTIVE_BALANCE_INCREMENT), + } + } + + /// Whether the chain is in an inactivity leak. + pub fn is_leaking(&self) -> bool { + self.leaking + } + + /// The `(reward, penalty)` of each timeliness flag, in flag order. + /// + /// All zero for an ineligible validator. Arithmetic and operation order + /// are `get_flag_index_deltas`'s, so the result is bit-identical. + pub fn flag_deltas( + &self, + flags: EpochFlags, + effective_balance: Gwei, + ) -> [(Gwei, Gwei); FLAG_COUNT] { + let mut deltas = [(0, 0); FLAG_COUNT]; + if !flags.is_eligible() { + return deltas; + } + let increments = effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + let base_reward = increments * self.base_reward_per_increment; + for (flag_index, delta) in deltas.iter_mut().enumerate() { + let weight = constants::PARTICIPATION_FLAG_WEIGHTS[flag_index]; + if flags.participated(flag_index) { + if !self.leaking { + let reward_numerator = + base_reward * weight * self.participating_increments[flag_index]; + delta.0 += + reward_numerator / (self.active_increments * constants::WEIGHT_DENOMINATOR); + } + } else if flag_index != constants::TIMELY_HEAD_FLAG_INDEX { + delta.1 += base_reward * weight / constants::WEIGHT_DENOMINATOR; + } + } + deltas + } + + /// The inactivity penalty, as `get_inactivity_penalty_deltas` computes it. + /// + /// `score` is only called (and so the score only looked up) for an + /// eligible validator that did not participate in the target, the one + /// case that reads it; its error is passed through. Fails with + /// [`Error::ArithmeticOverflow`] when `effective_balance * score` + /// overflows. + pub fn inactivity_penalty( + &self, + flags: EpochFlags, + effective_balance: Gwei, + score: impl FnOnce() -> Result, + config: &Config, + ) -> Result { + if !flags.is_eligible() || flags.participated(constants::TIMELY_TARGET_FLAG_INDEX) { + return Ok(0); + } + let penalty_numerator = + effective_balance + .checked_mul(score()?) + .ok_or(Error::ArithmeticOverflow( + "effective_balance * inactivity_scores[index]", + ))?; + let inactivity_penalty_quotient = preset::retuned::inactivity_penalty_quotient(self.fork); + let penalty_denominator = config.inactivity_score_bias * inactivity_penalty_quotient; + Ok(penalty_numerator / penalty_denominator) + } + + /// All four components for one validator. + /// + /// `score` is the validator's post-step-2 inactivity score, looked up + /// lazily (see [`Self::inactivity_penalty`]). + pub fn deltas( + &self, + flags: EpochFlags, + effective_balance: Gwei, + score: impl FnOnce() -> Result, + config: &Config, + ) -> Result { + let mut deltas = ValidatorDeltas::default(); + let flag_deltas = self.flag_deltas(flags, effective_balance); + deltas.0[..FLAG_COUNT].copy_from_slice(&flag_deltas); + deltas.0[ValidatorDeltas::INACTIVITY].1 = + self.inactivity_penalty(flags, effective_balance, score, config)?; + Ok(deltas) + } +} diff --git a/crates/blockchain/state_transition/src/beacon/helpers/participation_reference.rs b/crates/blockchain/state_transition/src/beacon/helpers/participation_reference.rs new file mode 100644 index 000000000..35596d369 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/participation_reference.rs @@ -0,0 +1,170 @@ +//! The specification-shaped implementations of altair's participation +//! helpers, kept as an oracle for [`super::participation`]. +//! +//! These are the functions as they were before the one-pass summary replaced +//! them: each rebuilds index lists and reads the registry by index. They are +//! compiled for tests and debug builds only, where the epoch driver runs them +//! on a clone and asserts the fast path agrees. + +use super::accessors::{ + get_active_validator_indices, get_current_epoch, get_previous_epoch, get_total_active_balance, + get_total_balance, +}; +use super::altair::{get_base_reward_per_increment, has_flag}; +use super::finality::{get_eligible_validator_indices, is_in_inactivity_leak}; +use crate::beacon::config::Config; +use crate::beacon::constants; +use crate::beacon::containers::BeaconState; +use crate::beacon::error::{Error, Result}; +use crate::beacon::preset; +use crate::beacon::primitives::{Epoch, Gwei, ValidatorIndex}; + +/// Reference for [`super::altair::get_unslashed_participating_indices`]. +pub fn get_unslashed_participating_indices( + state: &BeaconState, + flag_index: usize, + epoch: Epoch, +) -> Result> { + crate::beacon::verify( + epoch == get_previous_epoch(state) || epoch == get_current_epoch(state), + "epoch in (get_previous_epoch(state), get_current_epoch(state))", + )?; + + let (previous_epoch_participation, current_epoch_participation, _) = + state.altair_validator_lists()?; + let epoch_participation = if epoch == get_current_epoch(state) { + current_epoch_participation + } else { + previous_epoch_participation + }; + + let mut participating_indices = Vec::new(); + for index in get_active_validator_indices(state, epoch) { + let flags = + epoch_participation + .get(index as usize) + .copied() + .ok_or(Error::IndexOutOfBounds { + index: index as usize, + len: epoch_participation.len(), + })?; + if has_flag(flags, flag_index) && !state.validator(index)?.slashed { + participating_indices.push(index); + } + } + Ok(participating_indices) +} + +/// Reference for [`super::altair::get_flag_index_deltas`]. +pub fn get_flag_index_deltas( + state: &BeaconState, + flag_index: usize, +) -> Result<(Vec, Vec)> { + let validator_count = state.validators().len(); + let mut rewards = vec![0; validator_count]; + let mut penalties = vec![0; validator_count]; + + let previous_epoch = get_previous_epoch(state); + let unslashed_participating_indices = + get_unslashed_participating_indices(state, flag_index, previous_epoch)?; + let weight = constants::PARTICIPATION_FLAG_WEIGHTS[flag_index]; + let unslashed_participating_balance = + get_total_balance(state, &unslashed_participating_indices)?; + let unslashed_participating_increments = + unslashed_participating_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + let active_increments = get_total_active_balance(state)? / preset::EFFECTIVE_BALANCE_INCREMENT; + + // Hoisted out of the loop below, where the specification writes + // `get_base_reward(state, index)` per eligible validator. That helper is + // `increments * get_base_reward_per_increment(state)`, and the second + // factor is `get_total_active_balance`, an unconditional `O(registry + // size)` scan with no cache of its own. That is the same quantity + // `active_increments` above already paid for, just run through a + // different formula (`get_base_reward_per_increment` divides by + // `integer_squareroot`, `active_increments` does not), so it is not + // reusable as-is and has to be hoisted on its own. + // + // [`process_epoch::electra::process_epoch`] calls this (via + // `process_epoch::altair::process_rewards_and_penalties`) once per + // [`crate::beacon::constants::PARTICIPATION_FLAG_WEIGHTS`] entry, three times per + // epoch boundary. At mainnet's ~1M validators, the unhoisted form is + // three separate million-element scans per *eligible validator*, effectively + // unbounded, for what this function already computes once above. This is + // the same bug already fixed in `process_attestation`'s per-attester loop + // (see that function's own comment), left unfixed here because it runs + // once per epoch rather than once per block and so never showed up in a + // profile that did not cross an epoch boundary. + // + // Measured directly: `tests::measures_the_cost_of_get_flag_index_deltas` + // times this call at 2^15 validators. Unhoisted, that call took ~11.9s; + // hoisted, ~384us: roughly 31,000x at that scale, and the gap widens + // further at mainnet's ~2^20 validators, since the unhoisted form is + // O(n^2) (`1024x` slower again at that size) while this is O(n) (`32x` + // slower again, same as every other size-dependent cost in this crate). + let base_reward_per_increment = get_base_reward_per_increment(state)?; + + for index in get_eligible_validator_indices(state) { + // `get_base_reward(state, index)` inlined against the hoisted + // per-increment value, in 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 base_reward = increments * base_reward_per_increment; + if unslashed_participating_indices + .binary_search(&index) + .is_ok() + { + if !is_in_inactivity_leak(state) { + let reward_numerator = base_reward * weight * unslashed_participating_increments; + rewards[index as usize] += + reward_numerator / (active_increments * constants::WEIGHT_DENOMINATOR); + } + } else if flag_index != constants::TIMELY_HEAD_FLAG_INDEX { + penalties[index as usize] += base_reward * weight / constants::WEIGHT_DENOMINATOR; + } + } + Ok((rewards, penalties)) +} + +/// Reference for [`super::altair::get_inactivity_penalty_deltas`]. +pub fn get_inactivity_penalty_deltas( + state: &BeaconState, + config: &Config, +) -> Result<(Vec, Vec)> { + let validator_count = state.validators().len(); + let rewards = vec![0; validator_count]; + let mut penalties = vec![0; validator_count]; + + let previous_epoch = get_previous_epoch(state); + let matching_target_indices = get_unslashed_participating_indices( + state, + constants::TIMELY_TARGET_FLAG_INDEX, + previous_epoch, + )?; + + let (_, _, inactivity_scores) = state.altair_validator_lists()?; + + for index in get_eligible_validator_indices(state) { + if matching_target_indices.binary_search(&index).is_err() { + let effective_balance = state.validator(index)?.effective_balance; + let inactivity_score = + inactivity_scores + .get(index as usize) + .copied() + .ok_or(Error::IndexOutOfBounds { + index: index as usize, + len: inactivity_scores.len(), + })?; + + let penalty_numerator = effective_balance.checked_mul(inactivity_score).ok_or( + Error::ArithmeticOverflow("effective_balance * inactivity_scores[index]"), + )?; + let inactivity_penalty_quotient = + preset::retuned::inactivity_penalty_quotient(state.fork_name()); + let penalty_denominator = config.inactivity_score_bias * inactivity_penalty_quotient; + penalties[index as usize] += penalty_numerator / penalty_denominator; + } + } + + Ok((rewards, penalties)) +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs index 1b2f07320..ff85f5405 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs @@ -20,18 +20,13 @@ use crate::beacon::config::Config; use crate::beacon::constants; use crate::beacon::containers::BeaconState; use crate::beacon::error::{Error, Result}; -use crate::beacon::helpers::accessors::{ - get_current_epoch, get_previous_epoch, get_total_active_balance, get_total_balance, -}; -use crate::beacon::helpers::altair::{ - get_flag_index_deltas, get_inactivity_penalty_deltas, get_next_sync_committee, - get_unslashed_participating_indices, -}; -use crate::beacon::helpers::finality::{get_eligible_validator_indices, is_in_inactivity_leak}; +use crate::beacon::helpers::accessors::get_current_epoch; +use crate::beacon::helpers::altair::get_next_sync_committee; +use crate::beacon::helpers::finality::is_in_inactivity_leak; use crate::beacon::helpers::math::saturating_sub; -use crate::beacon::helpers::mutators::{decrease_balance, increase_balance}; +use crate::beacon::helpers::participation::{EpochSummary, ParticipationTotals, RewardContext}; use crate::beacon::preset; -use crate::beacon::primitives::ValidatorIndex; +use crate::beacon::primitives::{Gwei, ValidatorIndex}; use super::justification::weigh_justification_and_finalization; @@ -42,9 +37,7 @@ use super::justification::weigh_justification_and_finalization; /// update) to the fork-shared functions in [`super`], and substitutes /// altair's own version of the rest. pub fn process_epoch(state: &mut BeaconState, config: &Config) -> Result<()> { - process_justification_and_finalization(state)?; - process_inactivity_updates(state, config)?; - process_rewards_and_penalties(state, config)?; + process_participation_steps(state, config)?; super::registry::process_registry_updates(state, config)?; super::registry::process_slashings(state, config)?; super::process_eth1_data_reset(state)?; @@ -74,24 +67,17 @@ pub fn process_justification_and_finalization(state: &mut BeaconState) -> Result return Ok(()); } - let previous_indices = get_unslashed_participating_indices( - state, - constants::TIMELY_TARGET_FLAG_INDEX, - get_previous_epoch(state), - )?; - let current_indices = get_unslashed_participating_indices( - state, - constants::TIMELY_TARGET_FLAG_INDEX, - get_current_epoch(state), - )?; - let total_active_balance = get_total_active_balance(state)?; - let previous_target_balance = get_total_balance(state, &previous_indices)?; - let current_target_balance = get_total_balance(state, ¤t_indices)?; + let totals = ParticipationTotals::compute(state)?; + weigh_with_totals(state, &totals) +} + +/// Feeds `totals` to [`weigh_justification_and_finalization`]. +fn weigh_with_totals(state: &mut BeaconState, totals: &ParticipationTotals) -> Result<()> { weigh_justification_and_finalization( state, - total_active_balance, - previous_target_balance, - current_target_balance, + totals.total_active_balance, + totals.previous_epoch_flags[constants::TIMELY_TARGET_FLAG_INDEX], + totals.current_epoch_target, ) } @@ -116,51 +102,52 @@ pub fn process_inactivity_updates(state: &mut BeaconState, config: &Config) -> R return Ok(()); } - // Every read below needs `&BeaconState`, so they all run before this takes - // the mutable borrow `inactivity_scores` requires: `altair_validator_lists_mut` - // borrows the whole state, and there is no way to hold that mutably while - // also calling `get_eligible_validator_indices`, `get_unslashed_participating_indices`, - // or `is_in_inactivity_leak`, each of which needs its own `&BeaconState`. - // `process_effective_balance_updates` in the parent module resolves the - // identical conflict the same way: decide everything in one pass over - // immutable state, then apply it in a second pass over a mutable borrow. - let eligible_indices = get_eligible_validator_indices(state); - let previous_epoch = get_previous_epoch(state); - let participating_indices = get_unslashed_participating_indices( - state, - constants::TIMELY_TARGET_FLAG_INDEX, - previous_epoch, - )?; + let summary = EpochSummary::build(state, false)?; + update_inactivity_scores(state, config, &summary) +} + +/// Step 2 over an already-built summary. +/// +/// The leak flag is read here, so a driver must call this after step 1. +fn update_inactivity_scores( + state: &mut BeaconState, + config: &Config, + summary: &EpochSummary, +) -> Result<()> { let leaking = is_in_inactivity_leak(state); let (_, _, inactivity_scores) = state.altair_validator_lists_mut()?; let score_count = inactivity_scores.len(); - for index in eligible_indices { + for (index, (flags, _)) in summary.iter().enumerate() { + if !flags.is_eligible() { + continue; + } let score = inactivity_scores - .get_mut(index as usize) + .get_mut(index) .ok_or(Error::IndexOutOfBounds { - index: index as usize, + index, len: score_count, })?; - // `participating_indices` is ascending and duplicate-free (see - // `get_unslashed_participating_indices`), so membership is a binary - // search rather than a linear scan. - if participating_indices.binary_search(&index).is_ok() { + let mut updated = *score; + if flags.participated(constants::TIMELY_TARGET_FLAG_INDEX) { // `x -= min(1, x)`, written with `saturating_sub` so a // already-zero score cannot underflow. - *score = saturating_sub(*score, 1); + updated = saturating_sub(updated, 1); } else { // The specification treats a `uint64` overflow here as an invalid // state rather than a wrapped one, so this is checked rather than // left to release-mode wrapping. - *score = score.checked_add(config.inactivity_score_bias).ok_or( + updated = updated.checked_add(config.inactivity_score_bias).ok_or( Error::ArithmeticOverflow("inactivity_scores[index] + INACTIVITY_SCORE_BIAS"), )?; } - if !leaking { - *score = saturating_sub(*score, config.inactivity_score_recovery_rate); + updated = saturating_sub(updated, config.inactivity_score_recovery_rate); + } + + if updated != *score { + *score = updated; } } @@ -189,22 +176,155 @@ pub fn process_rewards_and_penalties(state: &mut BeaconState, config: &Config) - return Ok(()); } - let mut deltas = Vec::with_capacity(constants::PARTICIPATION_FLAG_WEIGHTS.len() + 1); - for flag_index in 0..constants::PARTICIPATION_FLAG_WEIGHTS.len() { - deltas.push(get_flag_index_deltas(state, flag_index)?); - } - deltas.push(get_inactivity_penalty_deltas(state, config)?); + let summary = EpochSummary::build(state, false)?; + let context = RewardContext::new(state, summary.totals()); + apply_rewards_and_penalties(state, config, &summary, &context) +} - let validator_count = state.validators().len() as ValidatorIndex; - for (rewards, penalties) in deltas { - for index in 0..validator_count { - increase_balance(state, index, rewards[index as usize])?; - decrease_balance(state, index, penalties[index as usize])?; +/// Step 3 over an already-built summary and reward context. +/// +/// Reads the scores step 2 wrote, so a driver must call this after it. The new +/// balances are decided over flat slices and only the ones that changed are +/// written back, so an ineligible validator's leaf in the balances tree stays +/// shared with the parent state. +fn apply_rewards_and_penalties( + state: &mut BeaconState, + config: &Config, + summary: &EpochSummary, + context: &RewardContext, +) -> Result<()> { + let (_, _, inactivity_scores) = state.altair_validator_lists()?; + let balances = state.balances(); + + let mut balance_iter = balances.iter(); + let mut changes: Vec<(usize, Gwei)> = Vec::new(); + for (index, (flags, effective_balance)) in summary.iter().enumerate() { + let balance = balance_iter.next().copied(); + if !flags.is_eligible() { + continue; + } + let deltas = context.deltas( + flags, + effective_balance, + || { + inactivity_scores + .get(index) + .copied() + .ok_or(Error::IndexOutOfBounds { + index, + len: inactivity_scores.len(), + }) + }, + config, + )?; + // A validator without a balance is only reported once every delta has + // been computed, the order the specification's two phases fail in. + if let Some(balance) = balance { + let updated = deltas.apply(balance); + if updated != balance { + changes.push((index, updated)); + } } } + if balances.len() < summary.len() { + // The specification touches every validator's balance, so a short list + // fails at its first missing entry. + return Err(Error::UnknownValidator(balances.len() as ValidatorIndex)); + } + + let balances = state.balances_mut(); + for (index, updated) in changes { + *balances + .get_mut(index) + .ok_or(Error::UnknownValidator(index as ValidatorIndex))? = updated; + } Ok(()) } +/// Steps 1-3 of every altair-through-fulu epoch, over one registry scan. +/// +/// The three steps are separate public functions for the fixtures, each +/// paying its own scan; the drivers call this instead so the scan, and the +/// summary it builds, is paid once. Same order and skip rules as running them +/// one after another: justification is skipped through the first two epochs, +/// the other two only at genesis, and the reward context is built after +/// justification because the leak flag reads the finalized checkpoint it may +/// have moved. +/// +/// In debug builds, with a registry small enough to afford it, the +/// specification-shaped steps run on a clone and the outcomes are asserted +/// equal (`release-fast` keeps debug assertions, so every fixture checks it). +pub(super) fn process_participation_steps(state: &mut BeaconState, config: &Config) -> Result<()> { + #[cfg(debug_assertions)] + { + /// Registries above this size skip the check: the reference is the + /// slow path this replaces. + const CHECK_LIMIT: usize = 4096; + if state.validators().len() <= CHECK_LIMIT { + let mut expected = state.clone(); + let expected_result = + super::altair_reference::process_participation_steps(&mut expected, config); + let result = run_participation_steps(state, config); + assert_eq!( + result.is_ok(), + expected_result.is_ok(), + "participation steps disagree with the reference: {result:?} vs {expected_result:?}" + ); + if result.is_ok() { + assert_participation_outcome_matches(state, &expected); + } + return result; + } + } + run_participation_steps(state, config) +} + +fn run_participation_steps(state: &mut BeaconState, config: &Config) -> Result<()> { + let current_epoch = get_current_epoch(state); + if current_epoch == constants::GENESIS_EPOCH { + return Ok(()); + } + + let justifies = current_epoch > constants::GENESIS_EPOCH + 1; + let summary = EpochSummary::build(state, justifies)?; + if justifies { + weigh_with_totals(state, summary.totals())?; + } + update_inactivity_scores(state, config, &summary)?; + let context = RewardContext::new(state, summary.totals()); + apply_rewards_and_penalties(state, config, &summary, &context) +} + +/// Asserts everything steps 1-3 write is identical between `state` and the +/// reference's `expected`. +#[cfg(debug_assertions)] +fn assert_participation_outcome_matches(state: &BeaconState, expected: &BeaconState) { + assert_eq!(state.balances(), expected.balances(), "balances"); + let (_, _, scores) = state.altair_validator_lists().expect("altair lists"); + let (_, _, expected_scores) = expected.altair_validator_lists().expect("altair lists"); + assert_eq!(scores, expected_scores, "inactivity scores"); + assert_eq!( + state.justification_bits(), + expected.justification_bits(), + "justification bits" + ); + assert_eq!( + state.previous_justified_checkpoint(), + expected.previous_justified_checkpoint(), + "previous justified checkpoint" + ); + assert_eq!( + state.current_justified_checkpoint(), + expected.current_justified_checkpoint(), + "current justified checkpoint" + ); + assert_eq!( + state.finalized_checkpoint(), + expected.finalized_checkpoint(), + "finalized checkpoint" + ); +} + /// Rotates the current epoch's participation flags into the previous slot and /// installs a fresh, all-zero current list. /// diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/altair_reference.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/altair_reference.rs new file mode 100644 index 000000000..eb9219232 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/altair_reference.rs @@ -0,0 +1,141 @@ +//! The specification-shaped altair steps 1-3, kept as an oracle for the +//! one-pass implementation in [`super::altair`]. +//! +//! These are the step functions as they were before +//! [`crate::beacon::helpers::participation`] replaced them: each reads the +//! registry through index lists and per-index descents. Compiled for tests +//! and debug builds only. [`super::altair::process_participation_steps`] runs +//! [`process_participation_steps`] on a clone and asserts the outcomes agree. + +use crate::beacon::config::Config; +use crate::beacon::constants; +use crate::beacon::containers::BeaconState; +use crate::beacon::error::{Error, Result}; +use crate::beacon::helpers::accessors::{ + get_current_epoch, get_previous_epoch, get_total_active_balance, get_total_balance, +}; +use crate::beacon::helpers::finality::{get_eligible_validator_indices, is_in_inactivity_leak}; +use crate::beacon::helpers::math::saturating_sub; +use crate::beacon::helpers::mutators::{decrease_balance, increase_balance}; +use crate::beacon::helpers::participation_reference::{ + get_flag_index_deltas, get_inactivity_penalty_deltas, get_unslashed_participating_indices, +}; +use crate::beacon::primitives::ValidatorIndex; + +use super::justification::weigh_justification_and_finalization; + +/// Steps 1-3 back to back, the way each fork's driver called them. +pub fn process_participation_steps(state: &mut BeaconState, config: &Config) -> Result<()> { + process_justification_and_finalization(state)?; + process_inactivity_updates(state, config)?; + process_rewards_and_penalties(state, config) +} + +/// Step 1, as the specification writes it. +pub fn process_justification_and_finalization(state: &mut BeaconState) -> Result<()> { + // Initial FFG checkpoint values have a `0x00` stub for `root`. Skip FFG + // updates in the first two epochs to avoid corner cases that might result + // in modifying this stub. + if get_current_epoch(state) <= constants::GENESIS_EPOCH + 1 { + return Ok(()); + } + + let previous_indices = get_unslashed_participating_indices( + state, + constants::TIMELY_TARGET_FLAG_INDEX, + get_previous_epoch(state), + )?; + let current_indices = get_unslashed_participating_indices( + state, + constants::TIMELY_TARGET_FLAG_INDEX, + get_current_epoch(state), + )?; + let total_active_balance = get_total_active_balance(state)?; + let previous_target_balance = get_total_balance(state, &previous_indices)?; + let current_target_balance = get_total_balance(state, ¤t_indices)?; + weigh_justification_and_finalization( + state, + total_active_balance, + previous_target_balance, + current_target_balance, + ) +} + +/// Step 2, as the specification writes it. +pub fn process_inactivity_updates(state: &mut BeaconState, config: &Config) -> Result<()> { + if get_current_epoch(state) == constants::GENESIS_EPOCH { + return Ok(()); + } + + // Every read below needs `&BeaconState`, so they all run before this takes + // the mutable borrow `inactivity_scores` requires: `altair_validator_lists_mut` + // borrows the whole state, and there is no way to hold that mutably while + // also calling `get_eligible_validator_indices`, `get_unslashed_participating_indices`, + // or `is_in_inactivity_leak`, each of which needs its own `&BeaconState`. + // `process_effective_balance_updates` in the parent module resolves the + // identical conflict the same way: decide everything in one pass over + // immutable state, then apply it in a second pass over a mutable borrow. + let eligible_indices = get_eligible_validator_indices(state); + let previous_epoch = get_previous_epoch(state); + let participating_indices = get_unslashed_participating_indices( + state, + constants::TIMELY_TARGET_FLAG_INDEX, + previous_epoch, + )?; + let leaking = is_in_inactivity_leak(state); + + let (_, _, inactivity_scores) = state.altair_validator_lists_mut()?; + let score_count = inactivity_scores.len(); + for index in eligible_indices { + let score = inactivity_scores + .get_mut(index as usize) + .ok_or(Error::IndexOutOfBounds { + index: index as usize, + len: score_count, + })?; + + // `participating_indices` is ascending and duplicate-free (see + // `get_unslashed_participating_indices`), so membership is a binary + // search rather than a linear scan. + if participating_indices.binary_search(&index).is_ok() { + // `x -= min(1, x)`, written with `saturating_sub` so a + // already-zero score cannot underflow. + *score = saturating_sub(*score, 1); + } else { + // The specification treats a `uint64` overflow here as an invalid + // state rather than a wrapped one, so this is checked rather than + // left to release-mode wrapping. + *score = score.checked_add(config.inactivity_score_bias).ok_or( + Error::ArithmeticOverflow("inactivity_scores[index] + INACTIVITY_SCORE_BIAS"), + )?; + } + + if !leaking { + *score = saturating_sub(*score, config.inactivity_score_recovery_rate); + } + } + + Ok(()) +} + +/// Step 3, as the specification writes it. +pub fn process_rewards_and_penalties(state: &mut BeaconState, config: &Config) -> Result<()> { + if get_current_epoch(state) == constants::GENESIS_EPOCH { + return Ok(()); + } + + let mut deltas = Vec::with_capacity(constants::PARTICIPATION_FLAG_WEIGHTS.len() + 1); + for flag_index in 0..constants::PARTICIPATION_FLAG_WEIGHTS.len() { + deltas.push(get_flag_index_deltas(state, flag_index)?); + } + deltas.push(get_inactivity_penalty_deltas(state, config)?); + + let validator_count = state.validators().len() as ValidatorIndex; + for (rewards, penalties) in deltas { + for index in 0..validator_count { + increase_balance(state, index, rewards[index as usize])?; + decrease_balance(state, index, penalties[index as usize])?; + } + } + Ok(()) +} diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/capella.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/capella.rs index 6a93c68b7..9baae4277 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/epoch/capella.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/capella.rs @@ -35,9 +35,7 @@ use crate::beacon::primitives::{Epoch, HashTreeRoot as _}; /// them; the one exception is [`process_historical_summaries_update`] in /// place of `super::process_historical_roots_update`. pub fn process_epoch(state: &mut BeaconState, config: &Config) -> Result<()> { - super::altair::process_justification_and_finalization(state)?; - super::altair::process_inactivity_updates(state, config)?; - super::altair::process_rewards_and_penalties(state, config)?; + super::altair::process_participation_steps(state, config)?; super::registry::process_registry_updates(state, config)?; super::registry::process_slashings(state, config)?; super::process_eth1_data_reset(state)?; diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/electra.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/electra.rs index 2f6bf9fcf..f44d6f285 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/epoch/electra.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/electra.rs @@ -66,9 +66,7 @@ use crate::beacon::primitives::{ /// update, so a deposit credited this epoch is already reflected when /// effective balances round toward it). pub fn process_epoch(state: &mut BeaconState, config: &Config) -> Result<()> { - super::altair::process_justification_and_finalization(state)?; - super::altair::process_inactivity_updates(state, config)?; - super::altair::process_rewards_and_penalties(state, config)?; + super::altair::process_participation_steps(state, config)?; // [Modified in Electra:EIP7251] process_registry_updates(state, config)?; // [Modified in Electra:EIP7251]: electra's own copy; see this module's @@ -647,8 +645,14 @@ pub fn process_effective_balance_updates(state: &mut BeaconState) -> Result<()> // (here) while calling `get_max_effective_balance` on the validator // being decided on. let mut updates = Vec::new(); - for (index, validator) in state.validators().iter().enumerate() { - let balance = state.balances()[index]; + // Zipped rather than indexed: step 3 only writes the balances that changed, + // so `balances()[index]` would be a tree descent for most validators. + for (index, (validator, &balance)) in state + .validators() + .iter() + .zip(state.balances().iter()) + .enumerate() + { if balance + DOWNWARD_THRESHOLD < validator.effective_balance || validator.effective_balance + UPWARD_THRESHOLD < balance { diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/mod.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/mod.rs index 026953803..84d336a26 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/epoch/mod.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/mod.rs @@ -15,10 +15,14 @@ //! is already exiting when the slashing penalty is scaled. pub mod altair; +#[cfg(any(test, debug_assertions))] +pub mod altair_reference; pub mod capella; pub mod electra; pub mod fulu; pub mod justification; +#[cfg(test)] +mod participation_equivalence; pub mod registry; pub mod rewards; @@ -272,8 +276,14 @@ pub fn process_effective_balance_updates(state: &mut BeaconState) -> Result<()> // fork-independent, which matters because every fork runs this step // unchanged. let mut updates = Vec::new(); - for (index, validator) in state.validators().iter().enumerate() { - let balance = state.balances()[index]; + // Zipped rather than indexed: step 3 only writes the balances that changed, + // so `balances()[index]` would be a tree descent for most validators. + for (index, (validator, &balance)) in state + .validators() + .iter() + .zip(state.balances().iter()) + .enumerate() + { if balance + DOWNWARD_THRESHOLD < validator.effective_balance || validator.effective_balance + UPWARD_THRESHOLD < balance { diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/participation_equivalence.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/participation_equivalence.rs new file mode 100644 index 000000000..242efd464 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/participation_equivalence.rs @@ -0,0 +1,548 @@ +//! Randomized equivalence of the one-pass participation accounting with the +//! specification-shaped reference it replaced. +//! +//! Every value the one-pass path is meant to reproduce is compared: the four +//! totals, each validator's flags, each component's `(reward, penalty)`, the +//! post-step scores and balances, the justification bits and checkpoints, and +//! the errors. States are crafted to reach what live chains do not: slashed and +//! exiting validators, effective balances off the increment or zero, scores +//! near `u64::MAX`, balances below one penalty, short lists, and the genesis +//! aliasing of the previous epoch's participation. + +use super::{altair, altair_reference}; +use crate::beacon::config::Config; +use crate::beacon::constants; +use crate::beacon::containers::BeaconState; +use crate::beacon::containers::shared::Checkpoint; +use crate::beacon::fork::ForkName; +use crate::beacon::helpers::accessors::{ + get_current_epoch, get_previous_epoch, get_total_active_balance, get_total_balance, +}; +use crate::beacon::helpers::altair as helpers; +use crate::beacon::helpers::participation::{ + EpochSummary, ParticipationTotals, RewardContext, ValidatorDeltas, +}; +use crate::beacon::helpers::participation_reference as reference; +use crate::beacon::helpers::test_state::with_validators_at; +use crate::beacon::preset; +use crate::beacon::primitives::{Gwei, Root, ValidatorIndex}; + +/// Small deterministic generator, so a failing case reproduces from its seed +/// without a new 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) + } + + /// Uniform in `0..bound`. + fn below(&mut self, bound: u64) -> u64 { + self.next() % bound + } + + /// True with probability `percent` in a hundred. + fn chance(&mut self, percent: u64) -> bool { + self.below(100) < percent + } + + fn pick(&mut self, items: &[T]) -> T { + items[self.below(items.len() as u64) as usize] + } +} + +const FORKS: [ForkName; 5] = [ + ForkName::Altair, + ForkName::Bellatrix, + ForkName::Capella, + ForkName::Electra, + ForkName::Fulu, +]; + +/// A random state at a random position, with `extreme` allowing the values +/// that overflow or empty balances. +fn random_state(rng: &mut SplitMix64, fork: ForkName) -> BeaconState { + let count = 1 + rng.below(300) as usize; + let mut state = with_validators_at(fork, count); + + let epoch = match rng.below(6) { + 0 => 0, + 1 => 1, + 2 => 2, + // Far enough past the genesis-finalized checkpoint to leak. + 3 => preset::MIN_EPOCHS_TO_INACTIVITY_PENALTY + 3 + rng.below(4), + _ => 3 + rng.below(40), + }; + // A non-zero offset lets the block root lookups for the current epoch + // succeed; zero exercises their error. + let offset = if rng.chance(90) { + 1 + rng.below(preset::SLOTS_PER_EPOCH - 1) + } else { + 0 + }; + *state.slot_mut() = epoch * preset::SLOTS_PER_EPOCH + offset; + + let extreme = rng.chance(15); + let participation_density = rng.pick(&[0, 30, 70, 95, 100]); + let slashed_density = rng.pick(&[0, 5, 20]); + let max_effective_balance = if matches!(fork, ForkName::Electra | ForkName::Fulu) { + preset::MAX_EFFECTIVE_BALANCE_ELECTRA + } else { + preset::MAX_EFFECTIVE_BALANCE + }; + + for index in 0..count { + let validator = state.validator_mut(index as ValidatorIndex).unwrap(); + // Epochs clustered around the previous and current ones so both + // boundaries of `is_active_validator` and the slashed-eligibility + // window are hit. + let near = |rng: &mut SplitMix64| (epoch + rng.below(5)).saturating_sub(2); + validator.activation_epoch = match rng.below(10) { + 0 => near(rng), + 1 => epoch + 1, + _ => 0, + }; + validator.exit_epoch = match rng.below(10) { + 0 => near(rng), + 1 => near(rng) + 1, + _ => constants::FAR_FUTURE_EPOCH, + }; + validator.slashed = rng.chance(slashed_density); + validator.withdrawable_epoch = match rng.below(4) { + 0 => near(rng), + 1 => near(rng) + 1, + _ => constants::FAR_FUTURE_EPOCH, + }; + validator.effective_balance = match rng.below(10) { + 0 => 0, + 1 => rng.below(max_effective_balance), + 2 => { + (1 + rng.below(max_effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT)) + * preset::EFFECTIVE_BALANCE_INCREMENT + } + _ => preset::MAX_EFFECTIVE_BALANCE, + }; + } + + let balances: Vec = (0..count) + .map(|index| { + let effective = state + .validator(index as ValidatorIndex) + .unwrap() + .effective_balance; + match rng.below(10) { + 0 => 0, + // Below one penalty, so a penalty empties it. + 1 => rng.below(5_000), + 2 if extreme => u64::MAX - rng.below(3), + 2 => rng.below(2 * effective + 1), + _ => effective, + } + }) + .collect(); + *state.balances_mut() = balances.try_into().unwrap(); + + let mut lists: Vec> = (0..2) + .map(|_| { + (0..count) + .map(|_| { + if rng.chance(participation_density) { + // All eight bit patterns, upper bits included: only + // the three flag bits may matter. + // Half of them full votes, so the two-thirds + // threshold is reachable. + if rng.chance(50) { + 0b111 | (rng.below(32) as u8) << 3 + } else { + rng.below(256) as u8 + } + } else { + 0 + } + }) + .collect() + }) + .collect(); + let mut scores: Vec = (0..count) + .map(|_| match rng.below(10) { + 0 if extreme => u64::MAX - rng.below(6), + 1 if extreme => u64::MAX / 2 + rng.below(1 << 20), + 0..=2 => 0, + 3..=6 => rng.below(64), + // Small enough that `effective_balance * score` does not + // overflow outside the `extreme` cases. + _ => rng.below(1 << 20), + }) + .collect(); + + // Short lists, which a live chain never has and the reference fails on. + if rng.chance(3) { + let keep = rng.below(count as u64) as usize; + match rng.below(3) { + 0 => lists[0].truncate(keep), + 1 => lists[1].truncate(keep), + _ => scores.truncate(keep), + } + } + if rng.chance(1) { + let keep = rng.below(count as u64) as usize; + let mut balances = state.balances().to_vec(); + balances.truncate(keep); + *state.balances_mut() = balances.try_into().unwrap(); + } + let (previous, current, inactivity) = state.altair_validator_lists_mut().unwrap(); + *current = lists.pop().unwrap().try_into().unwrap(); + *previous = lists.pop().unwrap().try_into().unwrap(); + *inactivity = scores.try_into().unwrap(); + + // Finality positioned so the four finalization rules can fire and the leak + // can start or not. + let checkpoint = |epoch: u64| Checkpoint { + epoch, + root: Root::ZERO, + }; + *state.previous_justified_checkpoint_mut() = checkpoint(epoch.saturating_sub(rng.below(4))); + *state.current_justified_checkpoint_mut() = checkpoint(epoch.saturating_sub(rng.below(3))); + *state.finalized_checkpoint_mut() = if rng.chance(50) { + checkpoint(0) + } else { + checkpoint(epoch.saturating_sub(rng.below(3))) + }; + for bit in 0..constants::JUSTIFICATION_BITS_LENGTH { + let value = rng.chance(50); + state.justification_bits_mut().set(bit, value).unwrap(); + } + state +} + +/// Two results agree when both succeed or both fail with the same error. +fn assert_same_outcome( + seed: u64, + what: &str, + fast: &crate::beacon::error::Result, + slow: &crate::beacon::error::Result, +) { + match (fast, slow) { + (Ok(_), Ok(_)) => {} + (Err(fast), Err(slow)) => { + assert_eq!( + fast.to_string(), + slow.to_string(), + "{what} error, seed {seed}" + ); + } + _ => panic!( + "{what} outcome differs, seed {seed}: fast ok={} slow ok={}", + fast.is_ok(), + slow.is_ok() + ), + } +} + +fn assert_same_state(seed: u64, what: &str, fast: &BeaconState, slow: &BeaconState) { + assert_eq!( + fast.balances(), + slow.balances(), + "{what} balances, seed {seed}" + ); + let (_, _, fast_scores) = fast.altair_validator_lists().unwrap(); + let (_, _, slow_scores) = slow.altair_validator_lists().unwrap(); + assert_eq!(fast_scores, slow_scores, "{what} scores, seed {seed}"); + assert_eq!( + fast.justification_bits(), + slow.justification_bits(), + "{what} bits, seed {seed}" + ); + assert_eq!( + fast.previous_justified_checkpoint(), + slow.previous_justified_checkpoint(), + "{what} previous justified, seed {seed}" + ); + assert_eq!( + fast.current_justified_checkpoint(), + slow.current_justified_checkpoint(), + "{what} current justified, seed {seed}" + ); + assert_eq!( + fast.finalized_checkpoint(), + slow.finalized_checkpoint(), + "{what} finalized, seed {seed}" + ); +} + +const CASES_PER_FORK: u64 = 300; + +fn for_each_state(mut check: impl FnMut(u64, &BeaconState)) { + for (fork_number, fork) in FORKS.into_iter().enumerate() { + for case in 0..CASES_PER_FORK { + let seed = (fork_number as u64) << 32 | case; + let mut rng = SplitMix64(seed); + let state = random_state(&mut rng, fork); + check(seed, &state); + } + } +} + +#[test] +fn drivers_match_the_reference_steps() { + let config = Config::mainnet(); + let (mut total, mut succeeded, mut balances_moved, mut justified, mut finalized) = + (0, 0, 0, 0, 0); + for_each_state(|seed, state| { + let mut fast = state.clone(); + let mut slow = state.clone(); + // The driver also asserts against the reference itself; the explicit + // comparison here adds the error text. + let fast_result = altair::process_participation_steps(&mut fast, &config); + let slow_result = altair_reference::process_participation_steps(&mut slow, &config); + assert_same_outcome(seed, "driver", &fast_result, &slow_result); + total += 1; + if fast_result.is_ok() { + assert_same_state(seed, "driver", &fast, &slow); + succeeded += 1; + balances_moved += (fast.balances() != state.balances()) as u32; + justified += (fast.current_justified_checkpoint() + != state.current_justified_checkpoint()) as u32; + finalized += (fast.finalized_checkpoint() != state.finalized_checkpoint()) as u32; + } + }); + // The generator has to reach the interesting outcomes, not only errors. + assert!( + succeeded * 2 > total, + "too many errors: {succeeded}/{total}" + ); + assert!(balances_moved > 100, "balances moved in {balances_moved}"); + assert!(justified > 20, "justification moved in {justified}"); + assert!(finalized > 5, "finalization moved in {finalized}"); +} + +#[test] +fn public_steps_match_the_reference_in_isolation() { + let config = Config::mainnet(); + for_each_state(|seed, state| { + let mut fast = state.clone(); + let mut slow = state.clone(); + let fast_result = altair::process_justification_and_finalization(&mut fast); + let slow_result = altair_reference::process_justification_and_finalization(&mut slow); + assert_same_outcome(seed, "justification", &fast_result, &slow_result); + if fast_result.is_ok() { + assert_same_state(seed, "justification", &fast, &slow); + } + + let mut fast = state.clone(); + let mut slow = state.clone(); + let fast_result = altair::process_inactivity_updates(&mut fast, &config); + let slow_result = altair_reference::process_inactivity_updates(&mut slow, &config); + assert_same_outcome(seed, "inactivity", &fast_result, &slow_result); + if fast_result.is_ok() { + assert_same_state(seed, "inactivity", &fast, &slow); + } + + let mut fast = state.clone(); + let mut slow = state.clone(); + let fast_result = altair::process_rewards_and_penalties(&mut fast, &config); + let slow_result = altair_reference::process_rewards_and_penalties(&mut slow, &config); + assert_same_outcome(seed, "rewards", &fast_result, &slow_result); + if fast_result.is_ok() { + assert_same_state(seed, "rewards", &fast, &slow); + } + }); +} + +#[test] +fn helpers_match_the_reference() { + let config = Config::mainnet(); + for_each_state(|seed, state| { + let previous = get_previous_epoch(state); + let current = get_current_epoch(state); + + for flag in 0..constants::PARTICIPATION_FLAG_WEIGHTS.len() { + for epoch in [previous, current] { + let fast = helpers::get_unslashed_participating_indices(state, flag, epoch); + let slow = reference::get_unslashed_participating_indices(state, flag, epoch); + assert_same_outcome(seed, "participating indices", &fast, &slow); + if let (Ok(fast), Ok(slow)) = (fast, slow) { + assert_eq!(fast, slow, "participating indices, seed {seed}"); + } + } + + let fast = helpers::get_flag_index_deltas(state, flag); + let slow = reference::get_flag_index_deltas(state, flag); + assert_same_outcome(seed, "flag deltas", &fast, &slow); + if let (Ok(fast), Ok(slow)) = (fast, slow) { + assert_eq!(fast, slow, "flag deltas {flag}, seed {seed}"); + } + } + + let fast = helpers::get_inactivity_penalty_deltas(state, &config); + let slow = reference::get_inactivity_penalty_deltas(state, &config); + assert_same_outcome(seed, "inactivity deltas", &fast, &slow); + if let (Ok(fast), Ok(slow)) = (fast, slow) { + assert_eq!(fast, slow, "inactivity deltas, seed {seed}"); + } + }); +} + +#[test] +fn summary_matches_the_reference() { + let config = Config::mainnet(); + for_each_state(|seed, state| { + let previous = get_previous_epoch(state); + let current = get_current_epoch(state); + let flag_count = constants::PARTICIPATION_FLAG_WEIGHTS.len(); + + // The reference's answer for each set, or its first error, previous + // epoch's list first as step 1 asks for them. + let previous_sets: Vec<_> = (0..flag_count) + .map(|flag| reference::get_unslashed_participating_indices(state, flag, previous)) + .collect(); + let current_target = reference::get_unslashed_participating_indices( + state, + constants::TIMELY_TARGET_FLAG_INDEX, + current, + ); + + let compute = ParticipationTotals::compute(state); + let previous_error = previous_sets.iter().find(|set| set.is_err()); + match (previous_error, ¤t_target, &compute) { + (Some(Err(expected)), _, Err(actual)) | (None, Err(expected), Err(actual)) => { + assert_eq!( + actual.to_string(), + expected.to_string(), + "totals error, seed {seed}" + ); + return; + } + (None, Ok(_), Ok(_)) => {} + _ => panic!("totals outcome differs, seed {seed}"), + } + let totals = compute.unwrap(); + + assert_eq!( + totals.total_active_balance, + get_total_active_balance(state).unwrap(), + "total active, seed {seed}" + ); + for (flag, set) in previous_sets.iter().enumerate() { + assert_eq!( + totals.previous_epoch_flags[flag], + get_total_balance(state, set.as_ref().unwrap()).unwrap(), + "previous flag {flag} total, seed {seed}" + ); + } + assert_eq!( + totals.current_epoch_target, + get_total_balance(state, current_target.as_ref().unwrap()).unwrap(), + "current target total, seed {seed}" + ); + + // Steps 2 and 3 never read the current epoch's list, so a summary + // built without it must not be failed by it, and its shared totals + // must agree. + let summary = EpochSummary::build(state, false).unwrap(); + assert_eq!( + summary.totals().total_active_balance, + totals.total_active_balance + ); + assert_eq!( + summary.totals().previous_epoch_flags, + totals.previous_epoch_flags + ); + let summary = EpochSummary::build(state, true).unwrap(); + assert_eq!(summary.totals(), &totals, "summary totals, seed {seed}"); + assert_eq!(summary.len(), state.validators().len()); + + let eligible = crate::beacon::helpers::finality::get_eligible_validator_indices(state); + for (index, (flags, effective_balance)) in summary.iter().enumerate() { + assert_eq!( + effective_balance, + state + .validator(index as ValidatorIndex) + .unwrap() + .effective_balance + ); + assert_eq!( + flags.is_eligible(), + eligible.binary_search(&(index as ValidatorIndex)).is_ok(), + "eligible {index}, seed {seed}" + ); + for (flag, set) in previous_sets.iter().enumerate() { + assert_eq!( + flags.participated(flag), + set.as_ref() + .unwrap() + .binary_search(&(index as ValidatorIndex)) + .is_ok(), + "flag {flag} of {index}, seed {seed}" + ); + } + } + + // Each component of `RewardContext::deltas` against the reference's + // vectors, and the balance it applies to against the spec's order. + let context = RewardContext::new(state, summary.totals()); + let (_, _, scores) = state.altair_validator_lists().unwrap(); + let flag_deltas: Vec<_> = (0..flag_count) + .map(|flag| reference::get_flag_index_deltas(state, flag).unwrap()) + .collect(); + let inactivity = reference::get_inactivity_penalty_deltas(state, &config); + let mut first_expected_error = None; + for (index, (flags, effective_balance)) in summary.iter().enumerate() { + let deltas = context.deltas( + flags, + effective_balance, + || { + scores.get(index).copied().ok_or( + crate::beacon::error::Error::IndexOutOfBounds { + index, + len: scores.len(), + }, + ) + }, + &config, + ); + let deltas = match deltas { + Ok(deltas) => deltas, + Err(error) => { + first_expected_error.get_or_insert(error.to_string()); + continue; + } + }; + for (flag, (rewards, penalties)) in flag_deltas.iter().enumerate() { + assert_eq!( + deltas.0[flag], + (rewards[index], penalties[index]), + "flag {flag} deltas of {index}, seed {seed}" + ); + } + if let Ok((_, penalties)) = &inactivity { + assert_eq!( + deltas.0[ValidatorDeltas::INACTIVITY], + (0, penalties[index]), + "inactivity delta of {index}, seed {seed}" + ); + } + } + match (&inactivity, first_expected_error) { + (Ok(_), None) => {} + (Err(expected), Some(actual)) => { + assert_eq!(actual, expected.to_string(), "delta error, seed {seed}"); + } + _ => panic!("inactivity delta outcome differs, seed {seed}"), + } + }); +} + +#[test] +fn applying_deltas_floors_after_each_component() { + // A reward arriving after a penalty that emptied the balance still counts; + // netting first would lose it. + let deltas = ValidatorDeltas([(0, 10), (5, 0), (0, 0), (0, 0)]); + assert_eq!(deltas.apply(3), 5); + let netted = 3u64.saturating_add(5).saturating_sub(10); + assert_ne!(deltas.apply(3), netted); +} diff --git a/docs/beacon_stf.md b/docs/beacon_stf.md index 17ebe686d..9aaec2f2e 100644 --- a/docs/beacon_stf.md +++ b/docs/beacon_stf.md @@ -293,6 +293,19 @@ over the registry should walk `validators().iter()`, zipped with candidate for computing once per epoch rather than per call, once it is shown that no block operation changes it mid-epoch. +Epoch steps 1-3 (justification, inactivity updates, rewards) follow that rule +through `helpers::participation`. One walk of `validators().iter()`, zipped with +the flat participation slices, produces each validator's flags, its effective +balance and the balance totals (an `EpochSummary`); the three steps then run +over flat data and write back only the balances that changed. The summary lives +for those three steps only, since registry updates change what it read. The +per-block pulled-up tip needs just the totals, which `ParticipationTotals` +computes without allocating. The fixtures still call each step alone, so the +public step functions stay and each builds what it needs. The +specification-shaped implementation is kept in `participation_reference` and +`stf::epoch::altair_reference` for tests and debug builds: the driver runs it on +a clone for registries of up to 4096 validators and asserts the outcomes agree. + ## Macros and traits Two `macro_rules!` in the whole crate, both local, both replacing boilerplate that From c95022ad3ff68c7528330f2863ddddd0d3f4337b 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, 29 Sep 2026 13:31:49 -0300 Subject: [PATCH 2/4] test(beacon): keep random finalized epoch behind the previous epoch The participation equivalence generator could finalize the current epoch, which no reachable state does, so get_finality_delay underflowed. The suite runs under release-fast, where overflow checks are off and both the fast path and the reference wrapped identically; the minimal-preset CI job runs a dev build and panicked. --- .../src/beacon/stf/epoch/participation_equivalence.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/participation_equivalence.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/participation_equivalence.rs index 242efd464..1537e2f8d 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/epoch/participation_equivalence.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/participation_equivalence.rs @@ -200,17 +200,20 @@ fn random_state(rng: &mut SplitMix64, fork: ForkName) -> BeaconState { *inactivity = scores.try_into().unwrap(); // Finality positioned so the four finalization rules can fire and the leak - // can start or not. + // can start or not. The finalized epoch stays at or behind the previous + // one, as in any reachable state: `get_finality_delay` subtracts it from + // the previous epoch, which underflows otherwise. let checkpoint = |epoch: u64| Checkpoint { epoch, root: Root::ZERO, }; + let previous_epoch = get_previous_epoch(&state); *state.previous_justified_checkpoint_mut() = checkpoint(epoch.saturating_sub(rng.below(4))); *state.current_justified_checkpoint_mut() = checkpoint(epoch.saturating_sub(rng.below(3))); *state.finalized_checkpoint_mut() = if rng.chance(50) { checkpoint(0) } else { - checkpoint(epoch.saturating_sub(rng.below(3))) + checkpoint(previous_epoch.saturating_sub(rng.below(3))) }; for bit in 0..constants::JUSTIFICATION_BITS_LENGTH { let value = rng.chance(50); From d84f5164ceb987f3db261fbaae4fb962ef042c34 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, 29 Sep 2026 15:23:47 -0300 Subject: [PATCH 3/4] fix(beacon): return an error when the finality delay underflows get_finality_delay subtracted the finalized epoch from the previous epoch unchecked, so a finalized checkpoint past the previous epoch panicked in debug builds and wrapped into a permanent inactivity leak in release. The specification treats a uint64 underflow as an invalid state transition, and Lighthouse and Grandine reject it the same way, so the delay and the leak check now return ArithmeticOverflow, as the inactivity penalty's own checked product already does. No chain reaches that state, since justification only finalizes an epoch behind the current one. --- .../src/beacon/helpers/altair.rs | 4 +-- .../src/beacon/helpers/finality.rs | 21 +++++++++++--- .../src/beacon/helpers/participation.rs | 11 ++++--- .../beacon/helpers/participation_reference.rs | 2 +- .../src/beacon/stf/epoch/altair.rs | 6 ++-- .../src/beacon/stf/epoch/altair_reference.rs | 2 +- .../stf/epoch/participation_equivalence.rs | 2 +- .../src/beacon/stf/epoch/rewards.rs | 29 ++++++++++++++----- 8 files changed, 53 insertions(+), 24 deletions(-) diff --git a/crates/blockchain/state_transition/src/beacon/helpers/altair.rs b/crates/blockchain/state_transition/src/beacon/helpers/altair.rs index f18bedf00..c22fd63d2 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/altair.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/altair.rs @@ -327,7 +327,7 @@ pub fn get_flag_index_deltas( // at a time, so each call pays its own scan, which the epoch driver does // not (it builds a single summary for all steps). let summary = EpochSummary::build(state, false)?; - let context = RewardContext::new(state, summary.totals()); + let context = RewardContext::new(state, summary.totals())?; let mut rewards = vec![0; summary.len()]; let mut penalties = vec![0; summary.len()]; @@ -364,7 +364,7 @@ pub fn get_inactivity_penalty_deltas( config: &Config, ) -> Result<(Vec, Vec)> { let summary = EpochSummary::build(state, false)?; - let context = RewardContext::new(state, summary.totals()); + let context = RewardContext::new(state, summary.totals())?; let (_, _, inactivity_scores) = state.altair_validator_lists()?; let rewards = vec![0; summary.len()]; diff --git a/crates/blockchain/state_transition/src/beacon/helpers/finality.rs b/crates/blockchain/state_transition/src/beacon/helpers/finality.rs index 9c945c9bd..baf38cd4c 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/finality.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/finality.rs @@ -8,6 +8,7 @@ //! `helpers` depend on `stf` where the dependency otherwise runs the other way. use crate::beacon::containers::BeaconState; +use crate::beacon::error::{Error, Result}; use crate::beacon::preset; use crate::beacon::primitives::{Epoch, ValidatorIndex}; @@ -20,8 +21,18 @@ use super::predicates::is_active_validator; /// by one every further epoch the chain fails to finalize, which is what lets /// [`is_in_inactivity_leak`] and the inactivity penalty scale with how long the /// stall has lasted rather than firing at a fixed severity. -pub fn get_finality_delay(state: &BeaconState) -> Epoch { - get_previous_epoch(state) - state.finalized_checkpoint().epoch +/// +/// A finalized checkpoint past the previous epoch makes this a `uint64` +/// underflow, which the specification treats as an invalid state transition, +/// so it returns [`Error::ArithmeticOverflow`] rather than wrapping. No chain +/// reaches that state: justification only ever finalizes an epoch already +/// behind the current one. +pub fn get_finality_delay(state: &BeaconState) -> Result { + get_previous_epoch(state) + .checked_sub(state.finalized_checkpoint().epoch) + .ok_or(Error::ArithmeticOverflow( + "get_previous_epoch(state) - finalized_checkpoint.epoch", + )) } /// Whether the chain has gone long enough without finalizing that inactive @@ -32,8 +43,10 @@ pub fn get_finality_delay(state: &BeaconState) -> Epoch { /// stalls, the faster an inactive validator's share of the active set shrinks, /// until the honest, active minority eventually clears the two-thirds /// threshold on its own. -pub fn is_in_inactivity_leak(state: &BeaconState) -> bool { - get_finality_delay(state) > preset::MIN_EPOCHS_TO_INACTIVITY_PENALTY +/// +/// Fails wherever [`get_finality_delay`] does. +pub fn is_in_inactivity_leak(state: &BeaconState) -> Result { + Ok(get_finality_delay(state)? > preset::MIN_EPOCHS_TO_INACTIVITY_PENALTY) } /// Validators whose participation this epoch's rewards and penalties account diff --git a/crates/blockchain/state_transition/src/beacon/helpers/participation.rs b/crates/blockchain/state_transition/src/beacon/helpers/participation.rs index 894b96d32..35cec65be 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/participation.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/participation.rs @@ -309,9 +309,12 @@ impl RewardContext { /// Derives the constants from `totals` (taken before or after /// justification alike) and the state's finality, which must be the /// post-justification one. - pub fn new(state: &BeaconState, totals: &ParticipationTotals) -> Self { - Self { - leaking: super::finality::is_in_inactivity_leak(state), + /// + /// Fails when that finality is past the previous epoch (see + /// [`get_finality_delay`](super::finality::get_finality_delay)). + pub fn new(state: &BeaconState, totals: &ParticipationTotals) -> Result { + Ok(Self { + leaking: super::finality::is_in_inactivity_leak(state)?, fork: state.fork_name(), base_reward_per_increment: preset::EFFECTIVE_BALANCE_INCREMENT * preset::BASE_REWARD_FACTOR @@ -320,7 +323,7 @@ impl RewardContext { participating_increments: totals .previous_epoch_flags .map(|total| total / preset::EFFECTIVE_BALANCE_INCREMENT), - } + }) } /// Whether the chain is in an inactivity leak. diff --git a/crates/blockchain/state_transition/src/beacon/helpers/participation_reference.rs b/crates/blockchain/state_transition/src/beacon/helpers/participation_reference.rs index 35596d369..3102b3eb4 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/participation_reference.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/participation_reference.rs @@ -114,7 +114,7 @@ pub fn get_flag_index_deltas( .binary_search(&index) .is_ok() { - if !is_in_inactivity_leak(state) { + if !is_in_inactivity_leak(state)? { let reward_numerator = base_reward * weight * unslashed_participating_increments; rewards[index as usize] += reward_numerator / (active_increments * constants::WEIGHT_DENOMINATOR); diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs index ff85f5405..b9c6d4e2e 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs @@ -114,7 +114,7 @@ fn update_inactivity_scores( config: &Config, summary: &EpochSummary, ) -> Result<()> { - let leaking = is_in_inactivity_leak(state); + let leaking = is_in_inactivity_leak(state)?; let (_, _, inactivity_scores) = state.altair_validator_lists_mut()?; let score_count = inactivity_scores.len(); @@ -177,7 +177,7 @@ pub fn process_rewards_and_penalties(state: &mut BeaconState, config: &Config) - } let summary = EpochSummary::build(state, false)?; - let context = RewardContext::new(state, summary.totals()); + let context = RewardContext::new(state, summary.totals())?; apply_rewards_and_penalties(state, config, &summary, &context) } @@ -291,7 +291,7 @@ fn run_participation_steps(state: &mut BeaconState, config: &Config) -> Result<( weigh_with_totals(state, summary.totals())?; } update_inactivity_scores(state, config, &summary)?; - let context = RewardContext::new(state, summary.totals()); + let context = RewardContext::new(state, summary.totals())?; apply_rewards_and_penalties(state, config, &summary, &context) } diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/altair_reference.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/altair_reference.rs index eb9219232..2e400ae04 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/epoch/altair_reference.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/altair_reference.rs @@ -82,7 +82,7 @@ pub fn process_inactivity_updates(state: &mut BeaconState, config: &Config) -> R constants::TIMELY_TARGET_FLAG_INDEX, previous_epoch, )?; - let leaking = is_in_inactivity_leak(state); + let leaking = is_in_inactivity_leak(state)?; let (_, _, inactivity_scores) = state.altair_validator_lists_mut()?; let score_count = inactivity_scores.len(); diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/participation_equivalence.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/participation_equivalence.rs index 1537e2f8d..37badb776 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/epoch/participation_equivalence.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/participation_equivalence.rs @@ -487,7 +487,7 @@ fn summary_matches_the_reference() { // Each component of `RewardContext::deltas` against the reference's // vectors, and the balance it applies to against the spec's order. - let context = RewardContext::new(state, summary.totals()); + let context = RewardContext::new(state, summary.totals()).unwrap(); let (_, _, scores) = state.altair_validator_lists().unwrap(); let flag_deltas: Vec<_> = (0..flag_count) .map(|flag| reference::get_flag_index_deltas(state, flag).unwrap()) diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/rewards.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/rewards.rs index a6bfab32b..36aa43b6b 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/epoch/rewards.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/rewards.rs @@ -114,7 +114,7 @@ pub fn get_attestation_component_deltas( // the numerator and denominator well clear of `u64::MAX` on a // large validator set. let increment = preset::EFFECTIVE_BALANCE_INCREMENT; - if is_in_inactivity_leak(state) { + if is_in_inactivity_leak(state)? { rewards[index as usize] += get_base_reward(state, index)?; } else { let reward_numerator = @@ -236,7 +236,7 @@ pub fn get_inactivity_penalty_deltas( ) -> Result<(Vec, Vec)> { let mut penalties = vec![0; state.validators().len()]; - if is_in_inactivity_leak(state) { + if is_in_inactivity_leak(state)? { let matching_target_attestations = get_matching_target_attestations(state, get_previous_epoch(state))?; let matching_target_attesting_indices = @@ -258,10 +258,10 @@ pub fn get_inactivity_penalty_deltas( // the specification treats a `uint64` overflow here as an // invalid state, not as a penalty that silently wraps small. let penalty_numerator = effective_balance - .checked_mul(get_finality_delay(state)) + .checked_mul(get_finality_delay(state)?) .ok_or(Error::ArithmeticOverflow( - "scaling effective balance by the finality delay for the inactivity penalty", - ))?; + "scaling effective balance by the finality delay for the inactivity penalty", + ))?; penalties[index as usize] += penalty_numerator / preset::INACTIVITY_PENALTY_QUOTIENT; } @@ -335,13 +335,26 @@ mod tests { // epoch 0, with the finalized checkpoint left at its default (epoch // 0): finality has not fallen behind at all. let state = crate::beacon::helpers::test_state::with_validators(4); - assert_eq!(get_finality_delay(&state), 0); + assert_eq!(get_finality_delay(&state).unwrap(), 0); + } + + #[test] + fn finality_past_the_previous_epoch_is_an_error_not_a_wrap() { + // Current epoch 1, previous epoch 0: finalizing epoch 1 would make the + // delay `0 - 1`, an underflow the specification treats as invalid. + let mut state = crate::beacon::helpers::test_state::with_validators(4); + state.finalized_checkpoint_mut().epoch = 1; + assert!(matches!( + get_finality_delay(&state), + Err(Error::ArithmeticOverflow(_)) + )); + assert!(is_in_inactivity_leak(&state).is_err()); } #[test] fn current_finality_is_not_a_leak() { let state = crate::beacon::helpers::test_state::with_validators(4); - assert!(!is_in_inactivity_leak(&state)); + assert!(!is_in_inactivity_leak(&state).unwrap()); } #[test] @@ -352,7 +365,7 @@ mod tests { // `MIN_EPOCHS_TO_INACTIVITY_PENALTY` epochs ahead of it. *state.slot_mut() = preset::SLOTS_PER_EPOCH * (preset::MIN_EPOCHS_TO_INACTIVITY_PENALTY + 10); - assert!(is_in_inactivity_leak(&state)); + assert!(is_in_inactivity_leak(&state).unwrap()); } #[test] From 91787389bf993392eda5c12ab13038db35bb6a55 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, 29 Sep 2026 15:26:52 -0300 Subject: [PATCH 4/4] docs(beacon): record the once-per-step inactivity-leak check as a deviation Now that an underflowing finality delay is an error rather than a wrap, the point where the leak flag is read decides which states fail. The altair fast path reads it once per step where the specification reads it per validator, so a crafted state with the finalized epoch past the previous one fails here and can pass there. --- docs/beacon_stf.md | 3 +++ docs/spec_deviations.md | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/docs/beacon_stf.md b/docs/beacon_stf.md index 9aaec2f2e..666830603 100644 --- a/docs/beacon_stf.md +++ b/docs/beacon_stf.md @@ -305,6 +305,9 @@ public step functions stay and each builds what it needs. The specification-shaped implementation is kept in `participation_reference` and `stf::epoch::altair_reference` for tests and debug builds: the driver runs it on a clone for registries of up to 4096 validators and asserts the outcomes agree. +The leak flag is read once per step rather than once per validator, which fails +some states the specification accepts; see +[Spec Deviations](spec_deviations.md#the-inactivity-leak-check-runs-once-per-epoch-step-not-once-per-validator). ## Macros and traits diff --git a/docs/spec_deviations.md b/docs/spec_deviations.md index 7ff2d24e2..a5099a8f1 100644 --- a/docs/spec_deviations.md +++ b/docs/spec_deviations.md @@ -131,3 +131,30 @@ answers `404`, since no candidate matches. This was found by running a mainnet follower and pointing a second one at its API: before the fallback existed, the second died with `peer served no block at the anchor slot 15265888`. + +## The inactivity-leak check runs once per epoch step, not once per validator + +A state whose finalized checkpoint is past its previous epoch fails epoch +processing here even where the specification never reaches the failing check. + +- **ethlambda:** `get_finality_delay` + (`crates/blockchain/state_transition/src/beacon/helpers/finality.rs`) returns + `ArithmeticOverflow` when `previous_epoch - finalized_checkpoint.epoch` + underflows. From altair on, the inactivity-score update and + `RewardContext::new` read the leak flag once, before their loop over + validators, so either fails whenever the delay does. altair's + `get_inactivity_penalty_deltas` also builds a `RewardContext`, so it fails + too. phase0 reads the flag where the specification does. +- **consensus-specs:** the subtraction is the same, and a `uint64` underflow + makes the transition invalid. But `process_inactivity_updates` and + `get_flag_index_deltas` call `is_in_inactivity_leak` inside their loops, for + eligible (and, for rewards, participating) validators only, and altair's + `get_inactivity_penalty_deltas` never calls it. A state where no validator + reaches the check passes. +- **Consequence:** none on a real chain. Justification only finalizes an epoch + behind the current one, so the finalized epoch never passes the previous + epoch, and only a crafted pre-state tells the two apart. Lighthouse reads + the leak once per epoch as well (`process_epoch_single_pass`), failing on the + same `safe_sub`. The randomized equivalence tests keep the finalized epoch + behind the previous one for this reason: past it, the fast path and the + specification-shaped reference fail at different points.