diff --git a/crates/blockchain/state_transition/src/beacon/helpers/altair.rs b/crates/blockchain/state_transition/src/beacon/helpers/altair.rs index eaa008c2..18786ff1 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/altair.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/altair.rs @@ -31,11 +31,12 @@ //! 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::finality::is_in_inactivity_leak; +use super::predicates::is_active_validator; use crate::beacon::bls; use crate::beacon::config::Config; use crate::beacon::constants; -use crate::beacon::containers::shared::AttestationData; +use crate::beacon::containers::shared::{AttestationData, EpochParticipation, Validator}; use crate::beacon::containers::{BeaconState, altair}; use crate::beacon::error::{Error, Result}; use crate::beacon::fork::ForkName; @@ -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; @@ -205,22 +206,13 @@ pub fn get_base_reward(state: &BeaconState, index: ValidatorIndex) -> Result Result> { +/// At the genesis epoch the previous and current epoch coincide, and the +/// current list is the one read, exactly as the specification's +/// `epoch == get_current_epoch(state)` branch does. +fn epoch_participation(state: &BeaconState, epoch: Epoch) -> Result<&EpochParticipation> { crate::beacon::verify( epoch == get_previous_epoch(state) || epoch == get_current_epoch(state), "epoch in (get_previous_epoch(state), get_current_epoch(state))", @@ -228,29 +220,114 @@ pub fn get_unslashed_participating_indices( let (previous_epoch_participation, current_epoch_participation, _) = state.altair_validator_lists()?; - let epoch_participation = if epoch == get_current_epoch(state) { + Ok(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); +/// Calls `each` with the index and record of every active, unslashed validator +/// that had `flag_index` set for `epoch`, in ascending index order. +/// +/// One walk over the registry's leaves, reading the flat participation list by +/// position: no index `Vec` and no per-index registry descent, which is what +/// the callers below used to pay per participant. An active validator past the +/// end of the participation list is an error, as before, and is reported for +/// the lowest such index whether or not it holds the flag. +fn for_each_unslashed_participant( + state: &BeaconState, + flag_index: usize, + epoch: Epoch, + mut each: impl FnMut(usize, &Validator), +) -> Result<()> { + let epoch_participation = epoch_participation(state, epoch)?; + + for (index, validator) in state.validators().iter().enumerate() { + if !is_active_validator(validator, epoch) { + continue; + } + let flags = epoch_participation + .get(index) + .copied() + .ok_or(Error::IndexOutOfBounds { + index, + len: epoch_participation.len(), + })?; + if has_flag(flags, flag_index) && !validator.slashed { + each(index, validator); } } + Ok(()) +} + +/// The active, unslashed validators that had `flag_index` set for `epoch`. +/// +/// `epoch` must be the current or previous epoch, since those are the only +/// two altair keeps a participation record for (`current_epoch_participation` +/// and `previous_epoch_participation`, mirroring the two-epoch window phase0 +/// keeps for `PendingAttestation`s). +/// +/// Ascending and duplicate-free, since it walks the registry in index order. +/// Callers that only need the combined balance should use +/// [`get_unslashed_participating_balance`], which skips building this list. +pub fn get_unslashed_participating_indices( + state: &BeaconState, + flag_index: usize, + epoch: Epoch, +) -> Result> { + let mut participating_indices = Vec::new(); + for_each_unslashed_participant(state, flag_index, epoch, |index, _| { + participating_indices.push(index as ValidatorIndex); + })?; Ok(participating_indices) } +/// The combined effective balance of [`get_unslashed_participating_indices`]'s +/// result, floored at one increment like +/// [`get_total_balance`](super::accessors::get_total_balance). +/// +/// Exactly `get_total_balance(state, &get_unslashed_participating_indices(..)?)` +/// (same saturating sum, same floor, same errors), computed in the one pass +/// that finds the participants. +pub fn get_unslashed_participating_balance( + state: &BeaconState, + flag_index: usize, + epoch: Epoch, +) -> Result { + let mut total: Gwei = 0; + for_each_unslashed_participant(state, flag_index, epoch, |_, validator| { + total = total.saturating_add(validator.effective_balance); + })?; + Ok(total.max(preset::EFFECTIVE_BALANCE_INCREMENT)) +} + +/// The value [`get_total_active_balance`] returns, in one walk over the +/// registry's leaves. +/// +/// Sums every validator active at the current epoch (slashed ones included), +/// saturating, floored at one increment. It exists so the epoch steps do not +/// pay [`get_total_active_balance`]'s index `Vec` plus one registry descent +/// per active validator. +pub(crate) fn compute_total_active_balance(state: &BeaconState) -> Gwei { + let current_epoch = get_current_epoch(state); + let total = state + .validators() + .iter() + .filter(|validator| is_active_validator(validator, current_epoch)) + .fold(0, |total: Gwei, validator| { + total.saturating_add(validator.effective_balance) + }); + total.max(preset::EFFECTIVE_BALANCE_INCREMENT) +} + +/// [`get_base_reward_per_increment`] for an already-computed +/// `total_active_balance`, with the same formula and operation order. +pub(crate) fn base_reward_per_increment_from_total(total_active_balance: Gwei) -> Gwei { + preset::EFFECTIVE_BALANCE_INCREMENT * preset::BASE_REWARD_FACTOR + / integer_squareroot(total_active_balance) +} + /// Which of the three participation flags an attestation with `data`, /// included after `inclusion_delay` slots, satisfies. /// @@ -306,9 +383,10 @@ pub fn get_attestation_participation_flag_indices( /// The reward and penalty for one participation flag, for every validator. /// -/// Reuses [`get_eligible_validator_indices`] and [`is_in_inactivity_leak`] -/// from phase0's rewards module unchanged, since the specification does not -/// touch either of them in altair. +/// Applies [`get_eligible_validator_indices`](super::finality::get_eligible_validator_indices)'s +/// predicate inline (one walk instead of an index list) and reuses +/// [`is_in_inactivity_leak`] from phase0's rewards module unchanged, since the +/// specification does not touch either of them in altair. /// /// During an inactivity leak, a matching validator earns nothing here for /// this flag rather than the balance-weighted share the non-leaking branch @@ -325,62 +403,53 @@ pub fn get_flag_index_deltas( 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]; + // Also the bounds check for the loop below: an active validator past the + // end of the participation list fails here, so the loop only indexes the + // list for validators this call has already proven are in range. let unslashed_participating_balance = - get_total_balance(state, &unslashed_participating_indices)?; + get_unslashed_participating_balance(state, flag_index, previous_epoch)?; + let epoch_participation = epoch_participation(state, previous_epoch)?; + + let weight = constants::PARTICIPATION_FLAG_WEIGHTS[flag_index]; 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) { + let total_active_balance = compute_total_active_balance(state); + let active_increments = total_active_balance / preset::EFFECTIVE_BALANCE_INCREMENT; + + // `get_base_reward(state, index)` is `increments * + // get_base_reward_per_increment(state)`, and the second factor depends on + // the total active balance, a registry-wide sum. It is the same for every + // validator, so it is computed once here rather than per eligible + // validator (which made this function quadratic in the registry size). + let base_reward_per_increment = base_reward_per_increment_from_total(total_active_balance); + let leaking = is_in_inactivity_leak(state); + + // One walk over the registry: eligibility (same predicate as + // `get_eligible_validator_indices`) and participation are decided from the + // iterated validator, so no index list, binary search or descent is needed. + for (index, validator) in state.validators().iter().enumerate() { + let active = is_active_validator(validator, previous_epoch); + if !(active || (validator.slashed && previous_epoch + 1 < validator.withdrawable_epoch)) { + continue; + } // `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 increments = validator.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) { + // Only an active validator has a participation record that counts; + // an eligible but inactive (slashed, exited) one never participates, + // and its index may lie past the end of the list. + let participated = + active && !validator.slashed && has_flag(epoch_participation[index], flag_index); + if participated { + if !leaking { let reward_numerator = base_reward * weight * unslashed_participating_increments; - rewards[index as usize] += + rewards[index] += 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; + penalties[index] += base_reward * weight / constants::WEIGHT_DENOMINATOR; } } Ok((rewards, penalties)) @@ -415,37 +484,66 @@ pub fn get_inactivity_penalty_deltas( 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 target_participation = epoch_participation(state, 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) + let inactivity_penalty_quotient = + preset::retuned::inactivity_penalty_quotient(state.fork_name()); + + // A participation-list bounds error outranks a score error from an + // earlier validator (the participants are resolved before any score is + // read), so a score error is held back while the walk keeps looking for + // one. Both only occur on malformed states. + let mut deferred_error = None; + for (index, validator) in state.validators().iter().enumerate() { + let active = is_active_validator(validator, previous_epoch); + let flags = if active { + Some( + target_participation + .get(index) .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; + index, + len: target_participation.len(), + })?, + ) + } else { + None + }; + if deferred_error.is_some() { + continue; } + if !(active || (validator.slashed && previous_epoch + 1 < validator.withdrawable_epoch)) { + continue; + } + let participated = flags.is_some_and(|flags| { + !validator.slashed && has_flag(flags, constants::TIMELY_TARGET_FLAG_INDEX) + }); + if participated { + continue; + } + + let Some(&inactivity_score) = inactivity_scores.get(index) else { + deferred_error = Some(Error::IndexOutOfBounds { + index, + len: inactivity_scores.len(), + }); + continue; + }; + let Some(penalty_numerator) = validator.effective_balance.checked_mul(inactivity_score) + else { + deferred_error = Some(Error::ArithmeticOverflow( + "effective_balance * inactivity_scores[index]", + )); + continue; + }; + let penalty_denominator = config.inactivity_score_bias * inactivity_penalty_quotient; + penalties[index] += penalty_numerator / penalty_denominator; } - Ok((rewards, penalties)) + match deferred_error { + Some(error) => Err(error), + None => Ok((rewards, penalties)), + } } // --------------------------------------------------------------------------- @@ -488,6 +586,9 @@ pub(crate) fn altair_state_ref<'a>( } } +#[cfg(test)] +mod reference_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/blockchain/state_transition/src/beacon/helpers/altair/reference_tests.rs b/crates/blockchain/state_transition/src/beacon/helpers/altair/reference_tests.rs new file mode 100644 index 00000000..351eac74 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/helpers/altair/reference_tests.rs @@ -0,0 +1,499 @@ +//! Randomized equivalence tests for the single-pass altair epoch helpers. +//! +//! The functions below are the implementations these helpers had before they +//! were rewritten to walk the registry once: they build index lists, descend +//! the registry per index and binary-search. They are kept verbatim as the +//! reference the current code must match exactly, errors included, on states +//! only a fixture would reach (short participation lists, zero and off-grid +//! effective balances, scores near the top of the range). + +use super::*; +use crate::beacon::containers::shared::InactivityScores; +use crate::beacon::helpers::accessors::{get_active_validator_indices, get_total_balance}; +use crate::beacon::helpers::finality::get_eligible_validator_indices; +use crate::beacon::helpers::math::saturating_sub; +use crate::beacon::helpers::mutators::{decrease_balance, increase_balance}; +use crate::beacon::helpers::test_state::with_validators_at; +use crate::beacon::stf::epoch::altair as steps; +use crate::beacon::stf::epoch::justification::weigh_justification_and_finalization; + +// --------------------------------------------------------------------------- +// Reference implementations (the code as it was before the single pass) +// --------------------------------------------------------------------------- + +fn ref_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) +} + +fn ref_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 = + ref_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; + let base_reward_per_increment = get_base_reward_per_increment(state)?; + + for index in get_eligible_validator_indices(state) { + 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)) +} + +fn ref_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 = ref_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)) +} + +fn ref_process_justification_and_finalization(state: &mut BeaconState) -> Result<()> { + if get_current_epoch(state) <= constants::GENESIS_EPOCH + 1 { + return Ok(()); + } + + let previous_indices = ref_unslashed_participating_indices( + state, + constants::TIMELY_TARGET_FLAG_INDEX, + get_previous_epoch(state), + )?; + let current_indices = ref_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, + ) +} + +fn ref_process_inactivity_updates(state: &mut BeaconState, config: &Config) -> Result<()> { + if get_current_epoch(state) == constants::GENESIS_EPOCH { + return Ok(()); + } + + let eligible_indices = get_eligible_validator_indices(state); + let previous_epoch = get_previous_epoch(state); + let participating_indices = ref_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, + })?; + + if participating_indices.binary_search(&index).is_ok() { + *score = saturating_sub(*score, 1); + } else { + *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(()) +} + +fn ref_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(ref_flag_index_deltas(state, flag_index)?); + } + deltas.push(ref_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(()) +} + +// --------------------------------------------------------------------------- +// Randomized states +// --------------------------------------------------------------------------- + +/// SplitMix64: a small deterministic generator, enough for test inputs. +struct Rng(u64); + +impl Rng { + 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) + } + + fn below(&mut self, bound: u64) -> u64 { + self.next() % bound + } + + fn chance(&mut self, percent: u64) -> bool { + self.below(100) < percent + } + + fn pick(&mut self, options: &[T]) -> T { + options[self.below(options.len() as u64) as usize] + } +} + +/// The most an electra-era effective balance can hold. +const MAX_BALANCE: Gwei = 2_048_000_000_000; + +/// The current epochs the tests visit: the first three, an ordinary one, and +/// one far enough past the genesis-default finalized checkpoint to leak. +fn epoch_choices() -> [Epoch; 5] { + [0, 1, 2, 5, preset::MIN_EPOCHS_TO_INACTIVITY_PENALTY + 6] +} + +fn random_state(rng: &mut Rng, fork: ForkName, current_epoch: Epoch) -> BeaconState { + let count = 100 + rng.below(201) as usize; + let mut state = with_validators_at(fork, count); + *state.slot_mut() = current_epoch * preset::SLOTS_PER_EPOCH; + let previous_epoch = current_epoch.saturating_sub(1); + + // Finality anywhere at or before the previous epoch (a leak when it is + // far behind), which keeps the finality delay from underflowing. + let leaking_state = current_epoch > preset::MIN_EPOCHS_TO_INACTIVITY_PENALTY; + let finalized_epoch = if leaking_state && rng.chance(70) { + 0 + } else { + rng.below(previous_epoch + 1) + }; + state.finalized_checkpoint_mut().epoch = finalized_epoch; + state.current_justified_checkpoint_mut().epoch = rng.below(current_epoch + 1); + state.previous_justified_checkpoint_mut().epoch = rng.below(previous_epoch + 1); + let bits = rng.below(16); + for bit in 0..4 { + let _ = state + .justification_bits_mut() + .set(bit, bits >> bit & 1 == 1); + } + + let epochs_around = [ + 0, + previous_epoch.saturating_sub(1), + previous_epoch, + current_epoch, + current_epoch + 1, + current_epoch + 2, + ]; + let large_scores = rng.chance(15); + for index in 0..count { + let activation_epoch = if rng.chance(70) { + 0 + } else { + rng.pick(&epochs_around) + }; + let exit_epoch = if rng.chance(70) { + constants::FAR_FUTURE_EPOCH + } else { + rng.pick(&epochs_around) + }; + let withdrawable_epoch = if rng.chance(50) { + constants::FAR_FUTURE_EPOCH + } else { + rng.pick(&epochs_around) + }; + let effective_balance = match rng.below(6) { + 0 => 0, + 1 => preset::MAX_EFFECTIVE_BALANCE, + 2 => rng.below(MAX_BALANCE + 1), + _ => { + rng.below(MAX_BALANCE / preset::EFFECTIVE_BALANCE_INCREMENT + 1) + * preset::EFFECTIVE_BALANCE_INCREMENT + } + }; + let validator = state.validator_mut(index as u64).unwrap(); + validator.activation_epoch = activation_epoch; + validator.exit_epoch = exit_epoch; + validator.withdrawable_epoch = withdrawable_epoch; + validator.slashed = rng.chance(25); + validator.effective_balance = effective_balance; + + let balance = match rng.below(4) { + 0 => 0, + 1 => rng.below(1_000_000_000), + 2 => effective_balance, + _ => effective_balance.saturating_add(rng.below(2_000_000_000)), + }; + *state.balances_mut().get_mut(index).unwrap() = balance; + } + + let (previous, current, scores) = state.altair_validator_lists_mut().unwrap(); + for index in 0..count { + previous[index] = rng.below(8) as u8; + current[index] = rng.below(8) as u8; + scores[index] = if large_scores && rng.chance(4) { + u64::MAX - rng.below(10) + } else if rng.chance(10) { + rng.below(1 << 20) + } else { + rng.below(200) + }; + } + + // Some states have a list shorter than the registry, which only a fixture + // can build; both implementations must report the same error. + if rng.chance(8) { + let keep = rng.below(count as u64) as usize; + match rng.below(3) { + 0 => *previous = truncated_participation(previous, keep), + 1 => *current = truncated_participation(current, keep), + _ => *scores = truncated_scores(scores, keep), + } + } + + state +} + +fn truncated_participation(list: &EpochParticipation, keep: usize) -> EpochParticipation { + list.iter() + .copied() + .take(keep) + .collect::>() + .try_into() + .unwrap() +} + +fn truncated_scores(list: &InactivityScores, keep: usize) -> InactivityScores { + list.iter() + .copied() + .take(keep) + .collect::>() + .try_into() + .unwrap() +} + +fn for_random_states(seed: u64, mut check: impl FnMut(&mut Rng, &BeaconState)) { + let mut rng = Rng(seed); + for fork in [ForkName::Altair, ForkName::Electra] { + for current_epoch in epoch_choices() { + for _ in 0..12 { + let state = random_state(&mut rng, fork, current_epoch); + check(&mut rng, &state); + } + } + } +} + +fn debug(value: &T) -> String { + format!("{value:?}") +} + +/// Everything the three epoch steps can write. +fn snapshot(state: &BeaconState) -> String { + let (_, _, scores) = state.altair_validator_lists().unwrap(); + debug(&( + state.justification_bits().clone(), + state.previous_justified_checkpoint(), + state.current_justified_checkpoint(), + state.finalized_checkpoint(), + scores.iter().copied().collect::>(), + state.balances().iter().copied().collect::>(), + )) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[test] +fn participation_helpers_match_reference() { + for_random_states(1, |_, state| { + let current_epoch = get_current_epoch(state); + let epochs = [ + get_previous_epoch(state), + current_epoch, + // Neither current nor previous: both must refuse. + current_epoch + 3, + ]; + for flag_index in 0..constants::PARTICIPATION_FLAG_WEIGHTS.len() { + for epoch in epochs { + let expected = ref_unslashed_participating_indices(state, flag_index, epoch); + let actual = get_unslashed_participating_indices(state, flag_index, epoch); + assert_eq!(debug(&actual), debug(&expected)); + + let expected_balance = + expected.and_then(|indices| get_total_balance(state, &indices)); + let actual_balance = get_unslashed_participating_balance(state, flag_index, epoch); + assert_eq!(debug(&actual_balance), debug(&expected_balance)); + } + } + + let total = get_total_active_balance(state).unwrap(); + assert_eq!(compute_total_active_balance(state), total); + assert_eq!( + base_reward_per_increment_from_total(total), + get_base_reward_per_increment(state).unwrap(), + ); + }); +} + +#[test] +fn reward_deltas_match_reference() { + let config = Config::mainnet(); + for_random_states(2, |_, state| { + for flag_index in 0..constants::PARTICIPATION_FLAG_WEIGHTS.len() { + let expected = ref_flag_index_deltas(state, flag_index); + let actual = get_flag_index_deltas(state, flag_index); + assert_eq!(debug(&actual), debug(&expected), "flag {flag_index}"); + } + let expected = ref_inactivity_penalty_deltas(state, &config); + let actual = get_inactivity_penalty_deltas(state, &config); + assert_eq!(debug(&actual), debug(&expected)); + }); +} + +#[test] +fn epoch_steps_match_reference() { + let config = Config::mainnet(); + for_random_states(3, |_, state| { + let mut expected_state = state.clone(); + let mut actual_state = state.clone(); + let expected = ref_process_justification_and_finalization(&mut expected_state); + let actual = steps::process_justification_and_finalization(&mut actual_state); + assert_eq!(debug(&actual), debug(&expected)); + assert_eq!(snapshot(&actual_state), snapshot(&expected_state)); + + let mut expected_state = state.clone(); + let mut actual_state = state.clone(); + let expected = ref_process_inactivity_updates(&mut expected_state, &config); + let actual = steps::process_inactivity_updates(&mut actual_state, &config); + assert_eq!(debug(&actual), debug(&expected)); + assert_eq!(snapshot(&actual_state), snapshot(&expected_state)); + + let mut expected_state = state.clone(); + let mut actual_state = state.clone(); + let expected = ref_process_rewards_and_penalties(&mut expected_state, &config); + let actual = steps::process_rewards_and_penalties(&mut actual_state, &config); + assert_eq!(debug(&actual), debug(&expected)); + assert_eq!(snapshot(&actual_state), snapshot(&expected_state)); + + // The steps in driver order, so step 3 reads the scores step 2 wrote + // and the leak flag step 1's finality may have moved. + let mut expected_state = state.clone(); + let mut actual_state = state.clone(); + let expected = ref_process_justification_and_finalization(&mut expected_state) + .and_then(|()| ref_process_inactivity_updates(&mut expected_state, &config)) + .and_then(|()| ref_process_rewards_and_penalties(&mut expected_state, &config)); + let actual = steps::process_justification_and_finalization(&mut actual_state) + .and_then(|()| steps::process_inactivity_updates(&mut actual_state, &config)) + .and_then(|()| steps::process_rewards_and_penalties(&mut actual_state, &config)); + assert_eq!(debug(&actual), debug(&expected)); + assert_eq!(snapshot(&actual_state), snapshot(&expected_state)); + }); +} 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..13e09692 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs @@ -20,16 +20,15 @@ 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::accessors::{get_current_epoch, get_previous_epoch}; use crate::beacon::helpers::altair::{ - get_flag_index_deltas, get_inactivity_penalty_deltas, get_next_sync_committee, - get_unslashed_participating_indices, + compute_total_active_balance, get_flag_index_deltas, get_inactivity_penalty_deltas, + get_next_sync_committee, get_unslashed_participating_balance, has_flag, }; -use crate::beacon::helpers::finality::{get_eligible_validator_indices, is_in_inactivity_leak}; +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::predicates::is_active_validator; use crate::beacon::preset; use crate::beacon::primitives::ValidatorIndex; @@ -61,7 +60,7 @@ pub fn process_epoch(state: &mut BeaconState, config: &Config) -> Result<()> { /// timely, correct target vote for the previous and current epoch. /// /// The only difference from phase0's version: the two target balances come -/// from [`get_unslashed_participating_indices`] over the epoch's participation +/// from [`get_unslashed_participating_balance`] over the epoch's participation /// flags rather than from matching stored `PendingAttestation`s against the /// block root history. Once those two balances are in hand, the actual /// bitfield and finality bookkeeping is identical, so this hands off to @@ -74,19 +73,17 @@ pub fn process_justification_and_finalization(state: &mut BeaconState) -> Result return Ok(()); } - let previous_indices = get_unslashed_participating_indices( + let previous_target_balance = get_unslashed_participating_balance( state, constants::TIMELY_TARGET_FLAG_INDEX, get_previous_epoch(state), )?; - let current_indices = get_unslashed_participating_indices( + let current_target_balance = get_unslashed_participating_balance( 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 total_active_balance = compute_total_active_balance(state); weigh_justification_and_finalization( state, total_active_balance, @@ -116,51 +113,85 @@ 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); + // The decisions come from one immutable walk over the registry's leaves + // and are applied in a second pass over the mutable score list: + // `altair_validator_lists_mut` borrows the whole state, so the two cannot + // overlap. `process_effective_balance_updates` in the parent module + // resolves the identical conflict the same way. The decisions are one + // byte per validator, not index lists searched per validator. + const INELIGIBLE: u8 = 0; + const PARTICIPATED: u8 = 1; + const MISSED: u8 = 2; + 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); + // Not the genesis epoch (returned above), so "previous" is a distinct + // epoch with its own list, unlike the genesis case in + // `get_unslashed_participating_indices`. + let (previous_epoch_participation, _, _) = state.altair_validator_lists()?; + + let mut decisions = Vec::with_capacity(state.validators().len()); + for (index, validator) in state.validators().iter().enumerate() { + let active = is_active_validator(validator, previous_epoch); + // A participation record is only read for an active validator, as + // `get_unslashed_participating_indices` does, and a short list is an + // error there before any score is touched. + let flags = if active { + Some(previous_epoch_participation.get(index).copied().ok_or( + Error::IndexOutOfBounds { + index, + len: previous_epoch_participation.len(), + }, + )?) + } else { + None + }; + let eligible = + active || (validator.slashed && previous_epoch + 1 < validator.withdrawable_epoch); + decisions.push(if !eligible { + INELIGIBLE + } else if flags.is_some_and(|flags| { + !validator.slashed && has_flag(flags, constants::TIMELY_TARGET_FLAG_INDEX) + }) { + PARTICIPATED + } else { + MISSED + }); + } let (_, _, inactivity_scores) = state.altair_validator_lists_mut()?; let score_count = inactivity_scores.len(); - for index in eligible_indices { + for (index, decision) in decisions.into_iter().enumerate() { + if decision == INELIGIBLE { + 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 decision == PARTICIPATED { // `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); + } + // Most scores sit at zero on a healthy chain; skip the write. + if updated != *score { + *score = updated; } }