diff --git a/crates/blockchain/state_transition/src/beacon/helpers/altair.rs b/crates/blockchain/state_transition/src/beacon/helpers/altair.rs index eaa008c2..c22fd63d 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/finality.rs b/crates/blockchain/state_transition/src/beacon/helpers/finality.rs index 9c945c9b..baf38cd4 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/mod.rs b/crates/blockchain/state_transition/src/beacon/helpers/mod.rs index 3449b26a..cf5b91b3 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 00000000..35cec65b --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/participation.rs @@ -0,0 +1,411 @@ +//! 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. + /// + /// 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 + / 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 00000000..3102b3eb --- /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 1b2f0732..b9c6d4e2 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 leaking = is_in_inactivity_leak(state); + 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 00000000..2e400ae0 --- /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 6a93c68b..9baae427 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 2f6bf9fc..f44d6f28 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 02695380..84d336a2 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 00000000..37badb77 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/participation_equivalence.rs @@ -0,0 +1,551 @@ +//! 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. 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(previous_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()).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()) + .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/crates/blockchain/state_transition/src/beacon/stf/epoch/rewards.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/rewards.rs index a6bfab32..36aa43b6 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] diff --git a/docs/beacon_stf.md b/docs/beacon_stf.md index 17ebe686..66683060 100644 --- a/docs/beacon_stf.md +++ b/docs/beacon_stf.md @@ -293,6 +293,22 @@ 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. +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 Two `macro_rules!` in the whole crate, both local, both replacing boilerplate that diff --git a/docs/spec_deviations.md b/docs/spec_deviations.md index 7ff2d24e..a5099a8f 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.