Fix bugs in proposer calculation post-Fulu (#8101)

As identified by a researcher during the Fusaka security competition, we were computing the proposer index incorrectly in some places by computing without lookahead.


  - [x] Add "low level" checks to computation functions in `consensus/types` to ensure they error cleanly
- [x] Re-work the determination of proposer shuffling decision roots, which are now fork aware.
- [x] Re-work and simplify the beacon proposer cache to be fork-aware.
- [x] Optimise `with_proposer_cache` to use `OnceCell`.
- [x] All tests passing.
- [x] Resolve all remaining `FIXME(sproul)`s.
- [x] Unit tests for `ProtoBlock::proposer_shuffling_root_for_child_block`.
- [x] End-to-end regression test.
- [x] Test on pre-Fulu network.
- [x] Test on post-Fulu network.


Co-Authored-By: Michael Sproul <michael@sigmaprime.io>
This commit is contained in:
Michael Sproul
2025-09-27 00:44:50 +10:00
committed by GitHub
parent 20c6ce4553
commit c754234b2c
19 changed files with 765 additions and 351 deletions

View File

@@ -12,9 +12,9 @@ use crate::{BeaconChain, BeaconChainError, BeaconChainTypes};
use fork_choice::ExecutionStatus;
use lru::LruCache;
use once_cell::sync::OnceCell;
use safe_arith::SafeArith;
use smallvec::SmallVec;
use state_processing::state_advance::partial_state_advance;
use std::cmp::Ordering;
use std::num::NonZeroUsize;
use std::sync::Arc;
use types::non_zero_usize::new_non_zero_usize;
@@ -51,6 +51,34 @@ pub struct EpochBlockProposers {
pub(crate) proposers: SmallVec<[usize; TYPICAL_SLOTS_PER_EPOCH]>,
}
impl EpochBlockProposers {
pub fn new(epoch: Epoch, fork: Fork, proposers: Vec<usize>) -> Self {
Self {
epoch,
fork,
proposers: proposers.into(),
}
}
pub fn get_slot<E: EthSpec>(&self, slot: Slot) -> Result<Proposer, BeaconChainError> {
let epoch = slot.epoch(E::slots_per_epoch());
if epoch == self.epoch {
self.proposers
.get(slot.as_usize() % E::SlotsPerEpoch::to_usize())
.map(|&index| Proposer {
index,
fork: self.fork,
})
.ok_or(BeaconChainError::ProposerCacheOutOfBounds { slot, epoch })
} else {
Err(BeaconChainError::ProposerCacheWrongEpoch {
request_epoch: epoch,
cache_epoch: self.epoch,
})
}
}
}
/// A cache to store the proposers for some epoch.
///
/// See the module-level documentation for more information.
@@ -76,23 +104,8 @@ impl BeaconProposerCache {
) -> Option<Proposer> {
let epoch = slot.epoch(E::slots_per_epoch());
let key = (epoch, shuffling_decision_block);
let cache_opt = self.cache.get(&key).and_then(|cell| cell.get());
if let Some(cache) = cache_opt {
// This `if` statement is likely unnecessary, but it feels like good practice.
if epoch == cache.epoch {
cache
.proposers
.get(slot.as_usize() % E::SlotsPerEpoch::to_usize())
.map(|&index| Proposer {
index,
fork: cache.fork,
})
} else {
None
}
} else {
None
}
let cache = self.cache.get(&key)?.get()?;
cache.get_slot::<E>(slot).ok()
}
/// As per `Self::get_slot`, but returns all proposers in all slots for the given `epoch`.
@@ -142,11 +155,7 @@ impl BeaconProposerCache {
) -> Result<(), BeaconStateError> {
let key = (epoch, shuffling_decision_block);
if !self.cache.contains(&key) {
let epoch_proposers = EpochBlockProposers {
epoch,
fork,
proposers: proposers.into(),
};
let epoch_proposers = EpochBlockProposers::new(epoch, fork, proposers);
self.cache
.put(key, Arc::new(OnceCell::with_value(epoch_proposers)));
}
@@ -178,7 +187,12 @@ pub fn compute_proposer_duties_from_head<T: BeaconChainTypes>(
.ok_or(BeaconChainError::HeadMissingFromForkChoice(head_block_root))?;
// Advance the state into the requested epoch.
ensure_state_is_in_epoch(&mut state, head_state_root, request_epoch, &chain.spec)?;
ensure_state_can_determine_proposers_for_epoch(
&mut state,
head_state_root,
request_epoch,
&chain.spec,
)?;
let indices = state
.get_beacon_proposer_indices(request_epoch, &chain.spec)
@@ -186,13 +200,13 @@ pub fn compute_proposer_duties_from_head<T: BeaconChainTypes>(
let dependent_root = state
// The only block which decides its own shuffling is the genesis block.
.proposer_shuffling_decision_root(chain.genesis_block_root)
.proposer_shuffling_decision_root(chain.genesis_block_root, &chain.spec)
.map_err(BeaconChainError::from)?;
Ok((indices, dependent_root, execution_status, state.fork()))
}
/// If required, advance `state` to `target_epoch`.
/// If required, advance `state` to the epoch required to determine proposer indices in `target_epoch`.
///
/// ## Details
///
@@ -200,22 +214,33 @@ pub fn compute_proposer_duties_from_head<T: BeaconChainTypes>(
/// - No-op if `state.current_epoch() == target_epoch`.
/// - It must be the case that `state.canonical_root() == state_root`, but this function will not
/// check that.
pub fn ensure_state_is_in_epoch<E: EthSpec>(
pub fn ensure_state_can_determine_proposers_for_epoch<E: EthSpec>(
state: &mut BeaconState<E>,
state_root: Hash256,
target_epoch: Epoch,
spec: &ChainSpec,
) -> Result<(), BeaconChainError> {
match state.current_epoch().cmp(&target_epoch) {
// Protects against an inconsistent slot clock.
Ordering::Greater => Err(BeaconStateError::SlotOutOfBounds.into()),
// The state needs to be advanced.
Ordering::Less => {
let target_slot = target_epoch.start_slot(E::slots_per_epoch());
partial_state_advance(state, Some(state_root), target_slot, spec)
.map_err(BeaconChainError::from)
}
// The state is suitable, nothing to do.
Ordering::Equal => Ok(()),
// The decision slot is the end of an epoch, so we add 1 to reach the first slot of the epoch
// at which the shuffling is determined.
let minimum_slot = spec
.proposer_shuffling_decision_slot::<E>(target_epoch)
.safe_add(1)?;
let minimum_epoch = minimum_slot.epoch(E::slots_per_epoch());
// Before and after Fulu, the oldest epoch reachable from a state at epoch N is epoch N itself,
// i.e. we can never "look back".
let maximum_epoch = target_epoch;
if state.current_epoch() > maximum_epoch {
Err(BeaconStateError::SlotOutOfBounds.into())
} else if state.current_epoch() >= minimum_epoch {
// Fulu allows us to access shufflings in multiple epochs (thanks to lookahead).
// Pre-Fulu we expect `minimum_epoch == maximum_epoch`, and this branch covers that case.
Ok(())
} else {
// State's current epoch is less than the minimum epoch.
// Advance the state up to the minimum epoch.
partial_state_advance(state, Some(state_root), minimum_slot, spec)
.map_err(BeaconChainError::from)
}
}