Skip to content

Commit c754234

Browse files
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 <[email protected]>
1 parent 20c6ce4 commit c754234

File tree

19 files changed

+766
-352
lines changed

19 files changed

+766
-352
lines changed

beacon_node/beacon_chain/src/beacon_chain.rs

Lines changed: 126 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ use crate::attestation_verification::{
55
};
66
use crate::attester_cache::{AttesterCache, AttesterCacheKey};
77
use crate::beacon_block_streamer::{BeaconBlockStreamer, CheckCaches};
8-
use crate::beacon_proposer_cache::BeaconProposerCache;
9-
use crate::beacon_proposer_cache::compute_proposer_duties_from_head;
8+
use crate::beacon_proposer_cache::{
9+
BeaconProposerCache, EpochBlockProposers, ensure_state_can_determine_proposers_for_epoch,
10+
};
1011
use crate::blob_verification::{GossipBlobError, GossipVerifiedBlob};
1112
use crate::block_times_cache::BlockTimesCache;
1213
use crate::block_verification::POS_PANDA_BANNER;
@@ -4698,65 +4699,54 @@ impl<T: BeaconChainTypes> BeaconChain<T> {
46984699

46994700
// Compute the proposer index.
47004701
let head_epoch = cached_head.head_slot().epoch(T::EthSpec::slots_per_epoch());
4701-
let shuffling_decision_root = if head_epoch == proposal_epoch {
4702-
cached_head
4703-
.snapshot
4704-
.beacon_state
4705-
.proposer_shuffling_decision_root(proposer_head)?
4706-
} else {
4707-
proposer_head
4708-
};
4709-
let cached_proposer = self
4710-
.beacon_proposer_cache
4711-
.lock()
4712-
.get_slot::<T::EthSpec>(shuffling_decision_root, proposal_slot);
4713-
let proposer_index = if let Some(proposer) = cached_proposer {
4714-
proposer.index as u64
4715-
} else {
4716-
if head_epoch + self.config.sync_tolerance_epochs < proposal_epoch {
4717-
warn!(
4718-
msg = "this is a non-critical issue that can happen on unhealthy nodes or \
4719-
networks.",
4720-
%proposal_epoch,
4721-
%head_epoch,
4722-
"Skipping proposer preparation"
4723-
);
4724-
4725-
// Don't skip the head forward more than two epochs. This avoids burdening an
4726-
// unhealthy node.
4727-
//
4728-
// Although this node might miss out on preparing for a proposal, they should still
4729-
// be able to propose. This will prioritise beacon chain health over efficient
4730-
// packing of execution blocks.
4731-
return Ok(None);
4732-
}
4733-
4734-
let (proposers, decision_root, _, fork) =
4735-
compute_proposer_duties_from_head(proposal_epoch, self)?;
4736-
4737-
let proposer_offset = (proposal_slot % T::EthSpec::slots_per_epoch()).as_usize();
4738-
let proposer = *proposers
4739-
.get(proposer_offset)
4740-
.ok_or(BeaconChainError::NoProposerForSlot(proposal_slot))?;
4741-
4742-
self.beacon_proposer_cache.lock().insert(
4743-
proposal_epoch,
4744-
decision_root,
4745-
proposers,
4746-
fork,
4747-
)?;
4702+
let shuffling_decision_root = cached_head
4703+
.snapshot
4704+
.beacon_state
4705+
.proposer_shuffling_decision_root_at_epoch(proposal_epoch, proposer_head, &self.spec)?;
4706+
4707+
let Some(proposer_index) = self.with_proposer_cache(
4708+
shuffling_decision_root,
4709+
proposal_epoch,
4710+
|proposers| proposers.get_slot::<T::EthSpec>(proposal_slot).map(|p| p.index as u64),
4711+
|| {
4712+
if head_epoch + self.config.sync_tolerance_epochs < proposal_epoch {
4713+
warn!(
4714+
msg = "this is a non-critical issue that can happen on unhealthy nodes or \
4715+
networks",
4716+
%proposal_epoch,
4717+
%head_epoch,
4718+
"Skipping proposer preparation"
4719+
);
47484720

4749-
// It's possible that the head changes whilst computing these duties. If so, abandon
4750-
// this routine since the change of head would have also spawned another instance of
4751-
// this routine.
4752-
//
4753-
// Exit now, after updating the cache.
4754-
if decision_root != shuffling_decision_root {
4755-
warn!("Head changed during proposer preparation");
4756-
return Ok(None);
4721+
// Don't skip the head forward too many epochs. This avoids burdening an
4722+
// unhealthy node.
4723+
//
4724+
// Although this node might miss out on preparing for a proposal, they should
4725+
// still be able to propose. This will prioritise beacon chain health over
4726+
// efficient packing of execution blocks.
4727+
Err(Error::SkipProposerPreparation)
4728+
} else {
4729+
let head = self.canonical_head.cached_head();
4730+
Ok((
4731+
head.head_state_root(),
4732+
head.snapshot.beacon_state.clone(),
4733+
))
4734+
}
4735+
},
4736+
).map_or_else(|e| {
4737+
match e {
4738+
Error::ProposerCacheIncorrectState { .. } => {
4739+
warn!("Head changed during proposer preparation");
4740+
Ok(None)
4741+
}
4742+
Error::SkipProposerPreparation => {
4743+
// Warning logged for this above.
4744+
Ok(None)
4745+
}
4746+
e => Err(e)
47574747
}
4758-
4759-
proposer as u64
4748+
}, |value| Ok(Some(value)))? else {
4749+
return Ok(None);
47604750
};
47614751

47624752
// Get the `prev_randao` and parent block number.
@@ -4916,14 +4906,19 @@ impl<T: BeaconChainTypes> BeaconChain<T> {
49164906

49174907
// Only attempt a re-org if we have a proposer registered for the re-org slot.
49184908
let proposing_at_re_org_slot = {
4919-
// The proposer shuffling has the same decision root as the next epoch attestation
4920-
// shuffling. We know our re-org block is not on the epoch boundary, so it has the
4921-
// same proposer shuffling as the head (but not necessarily the parent which may lie
4922-
// in the previous epoch).
4923-
let shuffling_decision_root = info
4924-
.head_node
4925-
.next_epoch_shuffling_id
4926-
.shuffling_decision_block;
4909+
// We know our re-org block is not on the epoch boundary, so it has the same proposer
4910+
// shuffling as the head (but not necessarily the parent which may lie in the previous
4911+
// epoch).
4912+
let shuffling_decision_root = if self
4913+
.spec
4914+
.fork_name_at_slot::<T::EthSpec>(re_org_block_slot)
4915+
.fulu_enabled()
4916+
{
4917+
info.head_node.current_epoch_shuffling_id
4918+
} else {
4919+
info.head_node.next_epoch_shuffling_id
4920+
}
4921+
.shuffling_decision_block;
49274922
let proposer_index = self
49284923
.beacon_proposer_cache
49294924
.lock()
@@ -6558,6 +6553,70 @@ impl<T: BeaconChainTypes> BeaconChain<T> {
65586553
}
65596554
}
65606555

6556+
pub fn with_proposer_cache<V, E: From<BeaconChainError> + From<BeaconStateError>>(
6557+
&self,
6558+
shuffling_decision_block: Hash256,
6559+
proposal_epoch: Epoch,
6560+
accessor: impl Fn(&EpochBlockProposers) -> Result<V, BeaconChainError>,
6561+
state_provider: impl FnOnce() -> Result<(Hash256, BeaconState<T::EthSpec>), E>,
6562+
) -> Result<V, E> {
6563+
let cache_entry = self
6564+
.beacon_proposer_cache
6565+
.lock()
6566+
.get_or_insert_key(proposal_epoch, shuffling_decision_block);
6567+
6568+
// If the cache entry is not initialised, run the code to initialise it inside a OnceCell.
6569+
// This prevents duplication of work across multiple threads.
6570+
//
6571+
// If it is already initialised, then `get_or_try_init` will return immediately without
6572+
// executing the initialisation code at all.
6573+
let epoch_block_proposers = cache_entry.get_or_try_init(|| {
6574+
debug!(
6575+
?shuffling_decision_block,
6576+
%proposal_epoch,
6577+
"Proposer shuffling cache miss"
6578+
);
6579+
6580+
// Fetch the state on-demand if the required epoch was missing from the cache.
6581+
// If the caller wants to not compute the state they must return an error here and then
6582+
// catch it at the call site.
6583+
let (state_root, mut state) = state_provider()?;
6584+
6585+
// Ensure the state can compute proposer duties for `epoch`.
6586+
ensure_state_can_determine_proposers_for_epoch(
6587+
&mut state,
6588+
state_root,
6589+
proposal_epoch,
6590+
&self.spec,
6591+
)?;
6592+
6593+
// Sanity check the state.
6594+
let latest_block_root = state.get_latest_block_root(state_root);
6595+
let state_decision_block_root = state.proposer_shuffling_decision_root_at_epoch(
6596+
proposal_epoch,
6597+
latest_block_root,
6598+
&self.spec,
6599+
)?;
6600+
if state_decision_block_root != shuffling_decision_block {
6601+
return Err(Error::ProposerCacheIncorrectState {
6602+
state_decision_block_root,
6603+
requested_decision_block_root: shuffling_decision_block,
6604+
}
6605+
.into());
6606+
}
6607+
6608+
let proposers = state.get_beacon_proposer_indices(proposal_epoch, &self.spec)?;
6609+
Ok::<_, E>(EpochBlockProposers::new(
6610+
proposal_epoch,
6611+
state.fork(),
6612+
proposers,
6613+
))
6614+
})?;
6615+
6616+
// Run the accessor function on the computed epoch proposers.
6617+
accessor(epoch_block_proposers).map_err(Into::into)
6618+
}
6619+
65616620
/// Runs the `map_fn` with the committee cache for `shuffling_epoch` from the chain with head
65626621
/// `head_block_root`. The `map_fn` will be supplied two values:
65636622
///

beacon_node/beacon_chain/src/beacon_proposer_cache.rs

Lines changed: 63 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ use crate::{BeaconChain, BeaconChainError, BeaconChainTypes};
1212
use fork_choice::ExecutionStatus;
1313
use lru::LruCache;
1414
use once_cell::sync::OnceCell;
15+
use safe_arith::SafeArith;
1516
use smallvec::SmallVec;
1617
use state_processing::state_advance::partial_state_advance;
17-
use std::cmp::Ordering;
1818
use std::num::NonZeroUsize;
1919
use std::sync::Arc;
2020
use types::non_zero_usize::new_non_zero_usize;
@@ -51,6 +51,34 @@ pub struct EpochBlockProposers {
5151
pub(crate) proposers: SmallVec<[usize; TYPICAL_SLOTS_PER_EPOCH]>,
5252
}
5353

54+
impl EpochBlockProposers {
55+
pub fn new(epoch: Epoch, fork: Fork, proposers: Vec<usize>) -> Self {
56+
Self {
57+
epoch,
58+
fork,
59+
proposers: proposers.into(),
60+
}
61+
}
62+
63+
pub fn get_slot<E: EthSpec>(&self, slot: Slot) -> Result<Proposer, BeaconChainError> {
64+
let epoch = slot.epoch(E::slots_per_epoch());
65+
if epoch == self.epoch {
66+
self.proposers
67+
.get(slot.as_usize() % E::SlotsPerEpoch::to_usize())
68+
.map(|&index| Proposer {
69+
index,
70+
fork: self.fork,
71+
})
72+
.ok_or(BeaconChainError::ProposerCacheOutOfBounds { slot, epoch })
73+
} else {
74+
Err(BeaconChainError::ProposerCacheWrongEpoch {
75+
request_epoch: epoch,
76+
cache_epoch: self.epoch,
77+
})
78+
}
79+
}
80+
}
81+
5482
/// A cache to store the proposers for some epoch.
5583
///
5684
/// See the module-level documentation for more information.
@@ -76,23 +104,8 @@ impl BeaconProposerCache {
76104
) -> Option<Proposer> {
77105
let epoch = slot.epoch(E::slots_per_epoch());
78106
let key = (epoch, shuffling_decision_block);
79-
let cache_opt = self.cache.get(&key).and_then(|cell| cell.get());
80-
if let Some(cache) = cache_opt {
81-
// This `if` statement is likely unnecessary, but it feels like good practice.
82-
if epoch == cache.epoch {
83-
cache
84-
.proposers
85-
.get(slot.as_usize() % E::SlotsPerEpoch::to_usize())
86-
.map(|&index| Proposer {
87-
index,
88-
fork: cache.fork,
89-
})
90-
} else {
91-
None
92-
}
93-
} else {
94-
None
95-
}
107+
let cache = self.cache.get(&key)?.get()?;
108+
cache.get_slot::<E>(slot).ok()
96109
}
97110

98111
/// As per `Self::get_slot`, but returns all proposers in all slots for the given `epoch`.
@@ -142,11 +155,7 @@ impl BeaconProposerCache {
142155
) -> Result<(), BeaconStateError> {
143156
let key = (epoch, shuffling_decision_block);
144157
if !self.cache.contains(&key) {
145-
let epoch_proposers = EpochBlockProposers {
146-
epoch,
147-
fork,
148-
proposers: proposers.into(),
149-
};
158+
let epoch_proposers = EpochBlockProposers::new(epoch, fork, proposers);
150159
self.cache
151160
.put(key, Arc::new(OnceCell::with_value(epoch_proposers)));
152161
}
@@ -178,44 +187,60 @@ pub fn compute_proposer_duties_from_head<T: BeaconChainTypes>(
178187
.ok_or(BeaconChainError::HeadMissingFromForkChoice(head_block_root))?;
179188

180189
// Advance the state into the requested epoch.
181-
ensure_state_is_in_epoch(&mut state, head_state_root, request_epoch, &chain.spec)?;
190+
ensure_state_can_determine_proposers_for_epoch(
191+
&mut state,
192+
head_state_root,
193+
request_epoch,
194+
&chain.spec,
195+
)?;
182196

183197
let indices = state
184198
.get_beacon_proposer_indices(request_epoch, &chain.spec)
185199
.map_err(BeaconChainError::from)?;
186200

187201
let dependent_root = state
188202
// The only block which decides its own shuffling is the genesis block.
189-
.proposer_shuffling_decision_root(chain.genesis_block_root)
203+
.proposer_shuffling_decision_root(chain.genesis_block_root, &chain.spec)
190204
.map_err(BeaconChainError::from)?;
191205

192206
Ok((indices, dependent_root, execution_status, state.fork()))
193207
}
194208

195-
/// If required, advance `state` to `target_epoch`.
209+
/// If required, advance `state` to the epoch required to determine proposer indices in `target_epoch`.
196210
///
197211
/// ## Details
198212
///
199213
/// - Returns an error if `state.current_epoch() > target_epoch`.
200214
/// - No-op if `state.current_epoch() == target_epoch`.
201215
/// - It must be the case that `state.canonical_root() == state_root`, but this function will not
202216
/// check that.
203-
pub fn ensure_state_is_in_epoch<E: EthSpec>(
217+
pub fn ensure_state_can_determine_proposers_for_epoch<E: EthSpec>(
204218
state: &mut BeaconState<E>,
205219
state_root: Hash256,
206220
target_epoch: Epoch,
207221
spec: &ChainSpec,
208222
) -> Result<(), BeaconChainError> {
209-
match state.current_epoch().cmp(&target_epoch) {
210-
// Protects against an inconsistent slot clock.
211-
Ordering::Greater => Err(BeaconStateError::SlotOutOfBounds.into()),
212-
// The state needs to be advanced.
213-
Ordering::Less => {
214-
let target_slot = target_epoch.start_slot(E::slots_per_epoch());
215-
partial_state_advance(state, Some(state_root), target_slot, spec)
216-
.map_err(BeaconChainError::from)
217-
}
218-
// The state is suitable, nothing to do.
219-
Ordering::Equal => Ok(()),
223+
// The decision slot is the end of an epoch, so we add 1 to reach the first slot of the epoch
224+
// at which the shuffling is determined.
225+
let minimum_slot = spec
226+
.proposer_shuffling_decision_slot::<E>(target_epoch)
227+
.safe_add(1)?;
228+
let minimum_epoch = minimum_slot.epoch(E::slots_per_epoch());
229+
230+
// Before and after Fulu, the oldest epoch reachable from a state at epoch N is epoch N itself,
231+
// i.e. we can never "look back".
232+
let maximum_epoch = target_epoch;
233+
234+
if state.current_epoch() > maximum_epoch {
235+
Err(BeaconStateError::SlotOutOfBounds.into())
236+
} else if state.current_epoch() >= minimum_epoch {
237+
// Fulu allows us to access shufflings in multiple epochs (thanks to lookahead).
238+
// Pre-Fulu we expect `minimum_epoch == maximum_epoch`, and this branch covers that case.
239+
Ok(())
240+
} else {
241+
// State's current epoch is less than the minimum epoch.
242+
// Advance the state up to the minimum epoch.
243+
partial_state_advance(state, Some(state_root), minimum_slot, spec)
244+
.map_err(BeaconChainError::from)
220245
}
221246
}

0 commit comments

Comments
 (0)