diff --git a/CLAUDE.md b/CLAUDE.md index a151a49d9..f03cd2296 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -335,6 +335,7 @@ actual_slot = finalized_slot + 1 + relative_index - `fork_digest` is a 4-byte hex string (no `0x` prefix); currently the dummy `12345678` agreed across clients - Mesh size: 8 (6-12 bounds), heartbeat: 700ms - Beacon wire: `validate_messages()` is on, so every beacon message waits for a verdict (~4.2s before gossipsub's cache evicts it). Rules in `state_transition::beacon::gossip` (cheap half inline, stateful half on a bounded `spawn_blocking` task); plumbing in `p2p/src/beacon/verdict.rs`. Lean gossip still auto-forwards + - Beacon peer scoring: lighthouse's parameters ported in `p2p/src/beacon/scoring.rs` (block, aggregate, 64 attestation subnets, exits, slashings; columns and sync contributions unscored), refreshed every slot from the head's active validator count. Mesh-delivery scoring (P3) is gated off while the head lags more than 4 slots and for an epoch after, because a catching-up node `Ignore`s every aggregate and would otherwise graylist its own mesh. Every held fork digest is parametrized before `transition::apply` joins it (gossipsub never scores a mesh grafted before its topic had parameters); the previous digest's weights are zeroed at the switch, and the switch restarts the P3 warmup. The swarm disconnects peers below the graylist (-16000) every 10 s. Lean runs unscored. See [`docs/beacon_wire.md`](docs/beacon_wire.md#peer-scoring) - Data columns: every check runs in p2p. A column gossip did not accept (`Queue`/`Overloaded`), every fetched column, and parked columns replayed after their parent imports go through `column::chain_checks` in `p2p/src/beacon/column_checks.rs`. The chain actor stores what it gets unchecked; only debug builds re-run `chain_checks` there - Beacon subscribes seven global topics plus two node-id-derived subnet families: custody columns and backbone attestation subnets. One of the seven is @@ -961,6 +962,10 @@ behavior. justifications) lands; that is not gloas-specific. - `BlockProof` is the only pruned block table (below the finalized boundary); `get_signed_block` returns `None` for a pruned finalized block. +- `States`/`StateDiffs` are pruned below the finalized block's snapshot interval + by `prune_finalized_states` (lean: in `prune_old_data`; beacon: in + `evict_held_blocks_at_or_below_finality`), watermarked by + `KEY_STATES_PRUNED_BELOW` so ranges are adjacent and never overlap. - A `StateDiff` omits `config` and `validators`, trusting they never mutate; breaking that invariant would silently corrupt every reconstructed state. - `Metadata["config"]` is written at bootstrap; it doubles as the DB's @@ -987,14 +992,16 @@ behavior. in-memory map stays as a write-through cache in front of the table. Pruned on finalization to the same horizon as `LiveChain` (the finalized block's own slot, keeping that block's own entry). -- `DB_VERSION` is 6: `Config` gained the gloas schedule, timing and churn - keys, and it is SSZ-encoded under `KEY_CONFIG`, so a data directory written - by an earlier version decodes into the wrong fields. (5 was the - unrealized-justifications table, since a directory written before it has no - row there for any block it already imported; 4 was - `PRESET_BASE`/`CONFIG_NAME` at the front of `Config`; 3 was the runtime keys - a `config.yaml` supplies.) `Store::from_db_state` refuses any other version - outright; there is no migration. +- `DB_VERSION` is 7: `States` and `StateDiffs` moved from root keys to + slot||root keys (the block's slot, looked up from `BlockHeaders` by readers) + so `Store::prune_finalized_states` can range-delete finalized states; a + directory written by an earlier version holds rows no lookup finds. (6 was + the gloas schedule, timing and churn keys in `Config`, which is SSZ-encoded + under `KEY_CONFIG`; 5 was the unrealized-justifications table, since a + directory written before it has no row there for any block it already + imported; 4 was `PRESET_BASE`/`CONFIG_NAME` at the front of `Config`; 3 was + the runtime keys a `config.yaml` supplies.) `Store::from_db_state` refuses + any other version outright; there is no migration. ### State Root Computation - Always computed via `hash_tree_root()` after full state transition diff --git a/crates/blockchain/src/epoch_precompute.rs b/crates/blockchain/src/epoch_precompute.rs new file mode 100644 index 000000000..9c77e137a --- /dev/null +++ b/crates/blockchain/src/epoch_precompute.rs @@ -0,0 +1,369 @@ +//! Beacon epoch-transition precompute. +//! +//! The first block of an epoch runs `process_epoch` inline on the import path, +//! then pays the post-epoch rehash for its state-root check. Both can be done +//! before the block arrives, since the parent is known by the end of the last +//! slot of the previous epoch: clone the head state, advance it to the epoch's +//! first slot on a blocking worker, hash it, and park it in the store's state +//! cache under [`CacheKey::CheckpointState`]. `fork_choice::on_block` resumes +//! from that entry when it finds one, and falls back to the inline path when it +//! does not, so the precompute is a pure speed trade. +//! +//! Two triggers feed the same worker: +//! +//! | Trigger | When | Covers | +//! |---|---|---| +//! | [`Trigger::Head`] | an import leaves a last-slot block as head | a late last-slot block | +//! | [`Trigger::Timer`] | three quarters into a last slot | a skipped last slot | +//! +//! Either trigger advances the head across one epoch boundary at most. At most +//! one worker runs at a time, and a key already in the cache is never +//! recomputed. A block that arrives while the worker is still running imports +//! inline; the late result is stored and simply goes unused by that block. + +use std::sync::Arc; +use std::time::Duration; + +use ethlambda_state_transition::beacon::fork_choice; +use ethlambda_state_transition::beacon::helpers::misc::compute_start_slot_at_epoch; +use ethlambda_state_transition::metrics as stf_metrics; +use ethlambda_storage::{CacheKey, Chain}; +use ethlambda_types::ShortRoot; +use ethlambda_types::beacon::containers::BeaconState; +use ethlambda_types::beacon::preset; +use ethlambda_types::primitives::H256; +use spawned_concurrency::message::Message; +use spawned_concurrency::tasks::{Context, Handler, send_after}; +use tracing::{debug, info, warn}; + +use crate::BlockChainServer; + +/// What caused a precompute, and the label it is counted under. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Trigger { + /// An import made a last-slot block the head. + Head, + /// The three-quarter point of a last slot, carrying that slot. + Timer { slot: u64 }, +} + +impl Trigger { + fn label(self) -> &'static str { + match self { + Trigger::Head => "head", + Trigger::Timer { .. } => "timer", + } + } +} + +/// The state a precompute produces: the head's post-state advanced to the first +/// slot of `epoch`. The same identity as `CacheKey::CheckpointState`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PrecomputeKey { + pub(crate) epoch: u64, + pub(crate) root: H256, +} + +/// Everything [`decide`] reads, gathered so the decision is a pure function. +pub(crate) struct DecisionInputs { + pub(crate) trigger: Trigger, + pub(crate) syncing: bool, + pub(crate) head_slot: u64, + pub(crate) head_root: H256, + /// The wall-clock slot now. + pub(crate) current_slot: u64, + /// The worker running right now, if any. + pub(crate) in_flight: Option, + /// Whether the store already holds a state for the candidate key. + pub(crate) already_cached: bool, +} + +fn is_last_slot_of_epoch(slot: u64) -> bool { + (slot + 1).is_multiple_of(preset::SLOTS_PER_EPOCH) +} + +/// Whether to start a precompute, and for which key. +/// +/// `None` when the node is syncing, when the trigger does not sit on the last +/// slot of an epoch, when a worker is already running (at most one), or when +/// the store already holds the state. A head trigger also requires the head to +/// be fresh (at most one slot behind the wall clock): the sync tracker moves +/// once per slot, so a follower importing history would otherwise precompute +/// every epoch it replays. A timer trigger requires the head to be in the +/// timer slot's own epoch, so a worker crosses one epoch boundary at most: in +/// a network-wide stall the sync tracker reports the node synced, and a timer +/// epochs past a stalled head would otherwise replay every epoch in between. +pub(crate) fn decide(inputs: &DecisionInputs) -> Option { + if inputs.syncing || inputs.in_flight.is_some() { + return None; + } + let epoch = match inputs.trigger { + Trigger::Head => { + if !is_last_slot_of_epoch(inputs.head_slot) + || inputs.current_slot > inputs.head_slot.saturating_add(1) + { + return None; + } + inputs.head_slot / preset::SLOTS_PER_EPOCH + 1 + } + Trigger::Timer { slot } => { + // A head past the slot would be a stale message racing a newer + // import; one already at the boundary has nothing left to advance. + // A head before the slot's own epoch is a stalled chain: advancing + // it would replay every epoch it missed, and each later timer would + // replay them again under a new key from the same head. + let slot_epoch = slot / preset::SLOTS_PER_EPOCH; + if !is_last_slot_of_epoch(slot) + || inputs.head_slot > slot + || inputs.head_slot < compute_start_slot_at_epoch(slot_epoch) + { + return None; + } + slot_epoch + 1 + } + }; + if inputs.head_slot >= compute_start_slot_at_epoch(epoch) { + return None; + } + let key = PrecomputeKey { + epoch, + root: inputs.head_root, + }; + (!inputs.already_cached).then_some(key) +} + +/// Delay from the start of a slot to its three-quarter point. +pub(crate) fn three_quarter_slot(slot_duration_ms: u64) -> Duration { + Duration::from_millis(slot_duration_ms / 4 * 3) +} + +/// Self-message armed at a last slot's start, delivered at its three-quarter point. +pub(crate) struct EpochPrecomputeCheck { + pub(crate) slot: u64, +} +impl Message for EpochPrecomputeCheck { + type Result = (); +} + +/// A worker's result, sent back to the actor. +pub(crate) struct EpochPrecomputed { + pub(crate) key: PrecomputeKey, + pub(crate) result: Result, String>, +} +impl Message for EpochPrecomputed { + type Result = (); +} + +impl BlockChainServer { + /// Arm the three-quarter check if `slot` is the last of its epoch. + /// + /// Called from the once-per-slot tick, which fires at the slot boundary, so + /// the delay is the three-quarter point less whatever of the slot the tick + /// has already consumed (a tick delayed by a long import). + pub(crate) fn arm_epoch_precompute_check(&self, slot: u64, ctx: &Context) { + if self.store.chain() != Chain::Beacon || !is_last_slot_of_epoch(slot) { + return; + } + let slot_duration_ms = self.store.config().time_grid().milliseconds_per_slot; + let into_slot = Duration::from_millis(self.ms_into_slot(slot).max(0) as u64); + let delay = three_quarter_slot(slot_duration_ms).saturating_sub(into_slot); + send_after(delay, ctx.clone(), EpochPrecomputeCheck { slot }); + } + + /// Start a precompute for the current head if [`decide`] allows one. + pub(crate) fn maybe_start_epoch_precompute(&mut self, trigger: Trigger, ctx: &Context) { + if self.store.chain() != Chain::Beacon { + return; + } + let Some((head_slot, head_root)) = self.store.beacon_head() else { + return; + }; + let candidate_epoch = match trigger { + Trigger::Head => head_slot / preset::SLOTS_PER_EPOCH + 1, + Trigger::Timer { slot } => slot / preset::SLOTS_PER_EPOCH + 1, + }; + let already_cached = self + .store + .cached_state(CacheKey::CheckpointState { + epoch: candidate_epoch, + root: head_root, + }) + .is_some(); + let inputs = DecisionInputs { + trigger, + syncing: self.sync_status.is_syncing(), + head_slot, + head_root, + current_slot: self.wall_clock_slot(), + in_flight: self.epoch_precompute_in_flight, + already_cached, + }; + let Some(key) = decide(&inputs) else { + return; + }; + // The head's post-state is resident (it was just imported); a read that + // cannot find it means the head moved to a block this node has no state + // for, which a later trigger can retry. + let Ok(Some(head_state)) = self.store.get_state(&head_root) else { + return; + }; + + self.epoch_precompute_in_flight = Some(key); + stf_metrics::inc_epoch_precompute_started(trigger.label()); + debug!( + epoch = key.epoch, + head_slot, + head_root = %ShortRoot(&head_root.0), + trigger = trigger.label(), + "Starting epoch precompute" + ); + + let config = self.store.config(); + let actor = ctx.actor_ref(); + tokio::task::spawn_blocking(move || { + let result = { + let _timing = stf_metrics::time_epoch_precompute(); + fork_choice::advance_to_epoch_start(&head_state, key.epoch, &config) + }; + let result = result.map(Arc::new).map_err(|err| err.to_string()); + let _ = actor.send(EpochPrecomputed { key, result }); + }); + } +} + +impl Handler for BlockChainServer { + async fn handle(&mut self, msg: EpochPrecomputeCheck, ctx: &Context) { + self.maybe_start_epoch_precompute(Trigger::Timer { slot: msg.slot }, ctx); + } +} + +impl Handler for BlockChainServer { + async fn handle(&mut self, msg: EpochPrecomputed, _ctx: &Context) { + self.epoch_precompute_in_flight = None; + match msg.result { + Ok(state) => { + // Stored even if the block it was meant for already imported + // inline: attestation targets for the epoch read the same key. + self.store.cache_state( + CacheKey::CheckpointState { + epoch: msg.key.epoch, + root: msg.key.root, + }, + state, + ); + info!( + epoch = msg.key.epoch, + head_root = %ShortRoot(&msg.key.root.0), + "Epoch precompute stored" + ); + } + Err(err) => warn!( + epoch = msg.key.epoch, + head_root = %ShortRoot(&msg.key.root.0), + %err, + "Epoch precompute failed" + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SPE: u64 = preset::SLOTS_PER_EPOCH; + + fn inputs(trigger: Trigger, head_slot: u64) -> DecisionInputs { + DecisionInputs { + trigger, + syncing: false, + head_slot, + head_root: H256([7; 32]), + current_slot: head_slot, + in_flight: None, + already_cached: false, + } + } + + #[test] + fn a_last_slot_head_precomputes_the_next_epoch() { + let key = decide(&inputs(Trigger::Head, 2 * SPE - 1)).expect("starts"); + assert_eq!(key.epoch, 2); + assert_eq!(key.root, H256([7; 32])); + } + + #[test] + fn a_head_elsewhere_in_the_epoch_starts_nothing() { + assert!(decide(&inputs(Trigger::Head, SPE)).is_none()); + assert!(decide(&inputs(Trigger::Head, 2 * SPE - 2)).is_none()); + } + + #[test] + fn a_syncing_node_starts_nothing() { + let mut i = inputs(Trigger::Head, 2 * SPE - 1); + i.syncing = true; + assert!(decide(&i).is_none()); + } + + #[test] + fn a_stale_head_trigger_starts_nothing() { + let mut i = inputs(Trigger::Head, 2 * SPE - 1); + i.current_slot = 2 * SPE + 5; + assert!(decide(&i).is_none()); + } + + #[test] + fn a_running_worker_blocks_a_second_one() { + let mut i = inputs(Trigger::Head, 2 * SPE - 1); + i.in_flight = Some(PrecomputeKey { + epoch: 2, + root: H256([7; 32]), + }); + assert!(decide(&i).is_none()); + } + + #[test] + fn a_state_already_cached_is_not_recomputed() { + let mut i = inputs(Trigger::Timer { slot: 2 * SPE - 1 }, 2 * SPE - 3); + i.already_cached = true; + assert!(decide(&i).is_none()); + } + + #[test] + fn the_timer_covers_a_skipped_last_slot() { + let key = + decide(&inputs(Trigger::Timer { slot: 2 * SPE - 1 }, 2 * SPE - 3)).expect("starts"); + assert_eq!(key.epoch, 2); + } + + #[test] + fn the_timer_covers_an_epoch_whose_only_block_is_its_first() { + let key = decide(&inputs(Trigger::Timer { slot: 3 * SPE - 1 }, 2 * SPE)).expect("starts"); + assert_eq!(key.epoch, 3); + } + + #[test] + fn a_stale_timer_trigger_starts_nothing() { + // The timer's epoch holds no block: the head is the previous epoch's + // last slot. + assert!(decide(&inputs(Trigger::Timer { slot: 3 * SPE - 1 }, 2 * SPE - 1)).is_none()); + // A stall several epochs long. + assert!(decide(&inputs(Trigger::Timer { slot: 6 * SPE - 1 }, 2 * SPE - 5)).is_none()); + } + + #[test] + fn the_timer_ignores_slots_that_are_not_the_last() { + assert!(decide(&inputs(Trigger::Timer { slot: SPE }, SPE - 1)).is_none()); + } + + #[test] + fn the_timer_skips_a_head_already_at_the_boundary() { + assert!(decide(&inputs(Trigger::Timer { slot: 2 * SPE - 1 }, 2 * SPE)).is_none()); + } + + #[test] + fn three_quarters_follows_the_configured_slot_duration() { + assert_eq!(three_quarter_slot(12_000), Duration::from_secs(9)); + assert_eq!(three_quarter_slot(6_000), Duration::from_millis(4_500)); + } +} diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index c90615340..a9cd8fc1c 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -62,6 +62,7 @@ mod beacon_payloads; pub use beacon_payloads::checkpoint_hash; pub mod block_builder; pub(crate) mod coverage; +mod epoch_precompute; pub mod events; pub(crate) mod fork_choice_tree; pub mod import_timing; @@ -421,6 +422,7 @@ impl BlockChain { engine, safe_slots_to_import_optimistically, last_tick_instant: None, + epoch_precompute_in_flight: None, sync_status, sync_status_controller, events, @@ -547,6 +549,10 @@ pub struct BlockChainServer { /// Last tick instant for measuring interval duration. last_tick_instant: Option, + /// The epoch precompute worker running right now, if any (beacon only). At + /// most one runs at a time; see [`epoch_precompute`]. + epoch_precompute_in_flight: Option, + /// Stateful sync heuristic used by `lean_node_sync_status`. Also gates /// validator duties while syncing, unless that gating was disabled at /// startup via `--disable-duty-sync-gate` (then it is metric-only). On a @@ -1066,6 +1072,12 @@ impl BlockChainServer { self.redrive_missing_envelopes(); self.settle_envelopes().await; + // Beacon only (both calls are no-ops on lean): a last slot arms its + // three-quarter precompute check, and a redrive above may have made a + // last-slot block the head without passing through the block handler. + self.arm_epoch_precompute_check(slot, ctx); + self.maybe_start_epoch_precompute(epoch_precompute::Trigger::Head, ctx); + // Per-interval duties for this tick. Lean-only, so this is where a // beacon follower's tick ends: it has no validator duties (see // [`ChainDuties::Beacon`]), and everything a tick owes it happened @@ -1996,6 +2008,7 @@ impl BlockChainServer { engine, safe_slots_to_import_optimistically, last_tick_instant: None, + epoch_precompute_in_flight: None, sync_status: SyncStatusTracker::new(false), sync_status_controller: SyncStatusController::default(), events: ChainEvents::new(EventBus::default(), &store), @@ -3199,6 +3212,10 @@ impl BlockChainServer { /// import — the two places beacon finality can move — bounds that by the /// unfinalized window rather than by this node's uptime. /// + /// Also prunes the states of finalized snapshot intervals on beacon (see + /// [`Store::prune_finalized_states`]), which shares this horizon and these + /// two call sites; lean does that in `Store::prune_old_data` instead. + /// /// Also bounds the three beacon scratch caches with the same horizon, the /// execution-hash cache, the payload-link cache and the optimistic-root /// set, which is why the finalized slot is read before the "nothing is @@ -3251,6 +3268,23 @@ impl BlockChainServer { self.store .prune_beacon_block_payload_statuses(finalized_slot); + // Same horizon again: the states of finalized snapshot intervals. Beacon + // only, since lean prunes them inside `Store::prune_old_data`, and doing + // it here too would prune twice. A call with unchanged finality is one + // metadata read, which is what makes running it after every tick safe. + if self.store.chain() == Chain::Beacon + && let Some(pruned) = self + .store + .prune_finalized_states() + .expect("prune finalized states") + { + info!( + from_slot = pruned.start, + below_slot = pruned.end, + "Pruned finalized states" + ); + } + if self.blocks_awaiting_columns.is_empty() { return; } @@ -3874,6 +3908,10 @@ impl Handler for BlockChainServer { self.on_block(msg.block, timings).await; // A gloas envelope that arrived ahead of this block is judgeable now. self.settle_envelopes().await; + + // The import above may have left a last-slot block as head: the + // moment to start the next epoch's transition (no-op on lean). + self.maybe_start_epoch_precompute(epoch_precompute::Trigger::Head, ctx); } } @@ -4418,6 +4456,7 @@ mod tests { engine: None, safe_slots_to_import_optimistically: constants::SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY, last_tick_instant: None, + epoch_precompute_in_flight: None, sync_status: SyncStatusTracker::new(false), sync_status_controller: SyncStatusController::default(), duties: ChainDuties::Beacon, diff --git a/crates/blockchain/src/sync_status.rs b/crates/blockchain/src/sync_status.rs index 5e968a5c4..27dff999b 100644 --- a/crates/blockchain/src/sync_status.rs +++ b/crates/blockchain/src/sync_status.rs @@ -122,6 +122,12 @@ impl SyncStatusTracker { } } + /// Whether the tracker currently considers the node to be syncing, + /// regardless of whether that gates duties. + pub(crate) fn is_syncing(&self) -> bool { + self.syncing + } + pub(crate) fn duties_allowed(&self) -> bool { // Gate disabled: the syncing state is observe-only, never suppresses duties. !self.gate_duties || !self.syncing diff --git a/crates/blockchain/state_transition/src/beacon/block_production.rs b/crates/blockchain/state_transition/src/beacon/block_production.rs index ac47f526e..9c3d0f8ce 100644 --- a/crates/blockchain/state_transition/src/beacon/block_production.rs +++ b/crates/blockchain/state_transition/src/beacon/block_production.rs @@ -594,6 +594,10 @@ pub fn assemble_block( &ActiveBalanceCache::default(), options, )?; + // Fold the block's writes into their trees first, so the root is computed + // on (and cached in) the state's own nodes rather than a throwaway copy, + // as `state_transition` does before it checks a block's state root. + post.apply_pending_mutations(); block.state_root = post.hash_tree_root(); Ok(block) } diff --git a/crates/blockchain/state_transition/src/beacon/fork_choice.rs b/crates/blockchain/state_transition/src/beacon/fork_choice.rs index 8d5343443..068cd60a7 100644 --- a/crates/blockchain/state_transition/src/beacon/fork_choice.rs +++ b/crates/blockchain/state_transition/src/beacon/fork_choice.rs @@ -173,13 +173,14 @@ use tracing::{debug, error, warn}; use crate::beacon::config::Config; use crate::beacon::constants; +use crate::beacon::containers::shared::Validator; use crate::beacon::containers::{AttestationData, BeaconState, Checkpoint, SignedBeaconBlock}; use crate::beacon::containers::{bellatrix, deneb, electra, fulu, gloas, phase0}; use crate::beacon::error::{Error, Result, verify}; use crate::beacon::fork::ForkName; use crate::beacon::helpers::accessors::{ - CommitteeCache, CommitteeCacheExt, get_active_validator_indices, get_current_epoch, - get_total_active_balance, + ActiveBalanceCache, CommitteeCache, CommitteeCacheExt, get_active_validator_indices, + get_current_epoch, }; use crate::beacon::helpers::attestation as phase0_attestation; use crate::beacon::helpers::electra as electra_helpers; @@ -196,6 +197,7 @@ use crate::beacon::primitives::{ ValidatorIndex, }; use crate::beacon::stf; +use crate::metrics; // --------------------------------------------------------------------------- // LatestMessage, PowBlock, PayloadStatusV1, ForkChoiceNode, PayloadStatus @@ -210,8 +212,8 @@ use crate::beacon::stf; // keyed on plain `Root`s, and nothing about either type needs storage of its // own beyond what `BeaconScratch` already keeps. pub use ethlambda_types::beacon::fork_choice::{ - BlockPayloadLink, ForkChoiceNode, LatestMessage, PayloadStatus, PayloadStatusEnum, - PayloadStatusV1, PowBlock, + BlockPayloadLink, ForkChoiceNode, JustifiedBalances, LatestMessage, PayloadStatus, + PayloadStatusEnum, PayloadStatusV1, PowBlock, }; // --------------------------------------------------------------------------- @@ -1003,6 +1005,160 @@ pub fn checkpoint_state( Ok(state) } +/// The state of `parent_root` advanced to the first slot of `epoch`, computed +/// without the store. +/// +/// What [`checkpoint_state`] derives on a miss, taking the checkpoint block's +/// post-state as an argument so a worker thread can run it off the actor. +/// Hashes the result after flushing buffered writes, so the import that later +/// resumes from a clone of it finds the tree nodes' hashes already memoized +/// and pays only for the flat fields. A state already at or past the epoch's +/// first slot comes back unchanged, as `checkpoint_state` returns it. +pub fn advance_to_epoch_start( + parent_state: &BeaconState, + epoch: Epoch, + config: &Config, +) -> Result { + let target_slot = compute_start_slot_at_epoch(epoch); + let mut state = parent_state.clone(); + if state.slot() < target_slot { + stf::process_slots(&mut state, target_slot, config)?; + } + state.apply_pending_mutations(); + let _ = state.hash_tree_root(); + Ok(state) +} +/// Applies `signed_block` to a copy of its parent's post-state, resuming from +/// the precomputed epoch-boundary state when the block crosses into an epoch +/// and one is cached. +/// +/// Returns the copy with the transition's outcome, since a failed transition +/// still leaves the caller a state to inspect. Both starting points reach the +/// same post-state: the precomputed one is what `process_slots` would produce +/// from the parent. Either way the copy is independent, so a failing block +/// cannot corrupt a cached entry. +/// +/// This is also where the epoch-boundary balances of a crossing block are +/// built (see [`cache_boundary_balances`]): a block with empty slots between +/// its parent and its epoch's first slot makes `(epoch, parent_root)` an +/// epoch-boundary checkpoint, and the boundary state is right here. A +/// precompute hit builds from the precomputed state; a miss advances the +/// parent's copy to the epoch start once, builds from that, and carries on +/// from there to the block's slot, so `process_slots` still runs each slot +/// once. The balances come before the block is validated, which is harmless: +/// the boundary state is a function of the parent alone, so an invalid block +/// cannot make the entry wrong, and the key names a real parent either way. +#[allow(clippy::too_many_arguments)] +fn transition_block( + store: &Store, + parent_state: &BeaconState, + parent_root: Root, + signed_block: &SignedBeaconBlock, + validate_result: bool, + config: &Config, + engine: &stf::ExecutionEngine, + committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, +) -> (BeaconState, Result<()>) { + let block_slot = signed_block.slot(); + let epoch = compute_epoch_at_slot(block_slot); + let epoch_start = compute_start_slot_at_epoch(epoch); + // The epoch's first slot (or more) was empty: the parent is the epoch's + // checkpoint block. A block *at* the first slot is its own, cached from + // its post-state by the caller. + let crosses_with_empty_slots = parent_state.slot() < epoch_start && epoch_start < block_slot; + let boundary = Checkpoint { + epoch, + root: parent_root, + }; + + let Some(precomputed) = + precomputed_epoch_state(store, parent_state.slot(), parent_root, block_slot) + else { + let mut state = parent_state.clone(); + if !crosses_with_empty_slots { + let outcome = stf::state_transition( + &mut state, + signed_block, + validate_result, + config, + engine, + committees, + active_balances, + ); + return (state, outcome); + } + // The same `process_slots` steps `state_transition` would run, split + // at the epoch start so the boundary state can be read in between. + let outcome = stf::process_slots(&mut state, epoch_start, config) + .inspect(|()| cache_boundary_balances(store, boundary, &state)) + .and_then(|()| stf::process_slots(&mut state, block_slot, config)) + .and_then(|()| { + stf::apply_block( + &mut state, + signed_block, + validate_result, + config, + engine, + committees, + active_balances, + ) + }); + return (state, outcome); + }; + + if crosses_with_empty_slots { + cache_boundary_balances(store, boundary, &precomputed); + } + let mut state = (*precomputed).clone(); + // Only the slots skipped past the boundary are left to advance; none when + // the block sits on the boundary itself. + let outcome = if state.slot() < block_slot { + stf::process_slots(&mut state, block_slot, config) + } else { + Ok(()) + } + .and_then(|()| { + stf::apply_block( + &mut state, + signed_block, + validate_result, + config, + engine, + committees, + active_balances, + ) + }); + (state, outcome) +} + +/// The precomputed epoch-boundary state an import can resume from, if any. +/// +/// Applies to a block that crosses into a new epoch: its parent's post-state +/// is before the first slot of the block's epoch, and the block is at or after +/// that slot. The precompute worker stores it under the key +/// [`checkpoint_state`] uses for `(block_epoch, parent_root)`, since the two +/// name the same state. Counts a hit or a miss for every such import and for +/// no other. +fn precomputed_epoch_state( + store: &Store, + parent_slot: Slot, + parent_root: Root, + block_slot: Slot, +) -> Option> { + let epoch = compute_epoch_at_slot(block_slot); + let epoch_start = compute_start_slot_at_epoch(epoch); + if !(parent_slot < epoch_start && epoch_start <= block_slot) { + return None; + } + let cached = store.cached_state(CacheKey::CheckpointState { + epoch, + root: parent_root, + }); + crate::metrics::inc_epoch_precompute_lookups(if cached.is_some() { "hit" } else { "miss" }); + cached +} + // --------------------------------------------------------------------------- // get_forkchoice_store // --------------------------------------------------------------------------- @@ -1246,8 +1402,15 @@ pub fn get_ancestor_or_missing( // Committee-relative weight helpers // --------------------------------------------------------------------------- -/// A committee's share of `state`'s total active balance, scaled by -/// `committee_percent` out of one hundred. +/// A committee's share of the effective balance of `state`'s active, unslashed +/// validators, scaled by `committee_percent` out of one hundred. +/// +/// Slashed validators are left out of the total, as consensus-specs #5679 +/// (v1.7.0-beta.4) defines it: fork choice weighs none of their votes, so a +/// total that kept them would size the proposer boost and the reorg +/// thresholds against votes that can no longer be cast. Lighthouse already +/// computed it this way. It is therefore not `get_total_active_balance`, +/// which the state transition still uses. /// /// `committee_percent` is a plain percentage, not basis points: unlike the /// `*_due_bps` configuration values (fractions of [`Config::slot_duration_ms`] @@ -1256,9 +1419,35 @@ pub fn get_ancestor_or_missing( /// [`Config::proposer_score_boost`] and the two `Config::reorg_*_threshold` /// values it is called with are themselves already expressed on a 0-100 /// scale. -pub fn calculate_committee_fraction(state: &BeaconState, committee_percent: u64) -> Result { - let committee_weight = get_total_active_balance(state)? / preset::SLOTS_PER_EPOCH; - Ok(committee_weight.saturating_mul(committee_percent) / 100) +pub fn calculate_committee_fraction(state: &BeaconState, committee_percent: u64) -> Gwei { + let epoch = get_current_epoch(state); + let total_balance = state + .iter_validators() + .map(|validator| vote_balance(validator, epoch)) + .fold(0, Gwei::saturating_add); + committee_fraction( + total_balance.max(preset::EFFECTIVE_BALANCE_INCREMENT), + committee_percent, + ) +} + +/// The arithmetic of [`calculate_committee_fraction`], for a caller that +/// already holds the total (the [`JustifiedBalances`] snapshot's own). +pub fn committee_fraction(total_balance: Gwei, committee_percent: u64) -> Gwei { + let committee_weight = total_balance / preset::SLOTS_PER_EPOCH; + committee_weight.saturating_mul(committee_percent) / 100 +} + +/// The weight fork choice gives `validator`'s vote at `epoch`: its effective +/// balance while it is active and unslashed, zero otherwise. Both the per-vote +/// weights and the committee total sum this, so the two cannot disagree on +/// which validators count. +fn vote_balance(validator: &Validator, epoch: Epoch) -> Gwei { + if is_active_validator(validator, epoch) && !validator.slashed { + validator.effective_balance + } else { + 0 + } } /// The checkpoint block for `epoch`, on `root`'s chain: the ancestor of `root` @@ -1279,10 +1468,107 @@ pub fn get_checkpoint_block( /// See [`calculate_committee_fraction`] for why this divides by a bare /// `100` rather than [`constants::BASIS_POINTS`]. pub fn get_proposer_score(store: &Store, config: &Config) -> Result { - let justified_checkpoint = store.beacon_justified_checkpoint(); - let justified_state = checkpoint_state(store, &justified_checkpoint, config)?; - let committee_weight = get_total_active_balance(&justified_state)? / preset::SLOTS_PER_EPOCH; - Ok(committee_weight.saturating_mul(config.proposer_score_boost) / 100) + let balances = justified_balances(store, config)?; + Ok(committee_fraction( + balances.total_balance(), + config.proposer_score_boost, + )) +} + +/// The flat balances of the store's justified checkpoint state, looked up in +/// the store's per-checkpoint cache. +/// +/// Block import normally filled it already ([`cache_boundary_balances`] and +/// [`cache_first_slot_balances`]), so the tick that moves the justified +/// checkpoint costs a lookup and a head computation reads no state. A miss, +/// after a restart or for a checkpoint whose boundary import this node never +/// saw, builds it from [`checkpoint_state`] and caches the result. +/// +/// Keyed by the checkpoint itself, which every writer of the justified +/// checkpoint (`update_checkpoints` from three handlers, store construction, +/// restart) already changes, so none of them needs a hook. Both sources build +/// from the state [`checkpoint_state`] returns, the one `get_weight` reads, so +/// the snapshot is exactly what the specification's per-root definition sees; +/// a later post-state of the same epoch would not be, since `slashed` can +/// differ. A failed `checkpoint_state` fails the call, as it does for every +/// other fork-choice reader, and the head stays where it is. +pub fn justified_balances(store: &Store, config: &Config) -> Result> { + let checkpoint = store.beacon_justified_checkpoint(); + if let Some(balances) = store.justified_balances(&checkpoint) { + metrics::inc_justified_balances_lookups("hit"); + return Ok(balances); + } + metrics::inc_justified_balances_lookups("miss"); + let state = checkpoint_state(store, &checkpoint, config)?; + let balances = Arc::new(build_justified_balances(checkpoint, &state)); + store.insert_justified_balances(Arc::clone(&balances)); + Ok(balances) +} + +/// Caches the balances of the epoch-boundary checkpoint `checkpoint` from +/// `state`, the checkpoint's state at the epoch's first slot, unless the cache +/// has them. +fn cache_boundary_balances(store: &Store, checkpoint: Checkpoint, state: &BeaconState) { + if store.justified_balances(&checkpoint).is_none() { + let balances = Arc::new(build_justified_balances(checkpoint, state)); + store.insert_justified_balances(balances); + } +} + +/// A block at its epoch's first slot is its own epoch-boundary block, and its +/// post-state is what [`checkpoint_state`] returns for `(epoch, block_root)` +/// (no advance needed): cache the balances from it. +fn cache_first_slot_balances(store: &Store, post_state: &BeaconState, block_root: Root) { + let slot = post_state.slot(); + let epoch = compute_epoch_at_slot(slot); + if slot != compute_start_slot_at_epoch(epoch) { + return; + } + let checkpoint = Checkpoint { + epoch, + root: block_root, + }; + cache_boundary_balances(store, checkpoint, post_state); +} + +/// One in-order pass over `state`'s registry: each validator's vote weight +/// (zero if inactive or slashed), its raw effective balance in increments, and, +/// in the same pass, the sum of the vote weights, which is the total +/// [`calculate_committee_fraction`] divides (saturating, floored at one +/// increment). +fn build_justified_balances(checkpoint: Checkpoint, state: &BeaconState) -> JustifiedBalances { + let _timing = metrics::time_justified_balances_build(); + // Activity at the state's own epoch, as `get_weight` reads it; not asserted + // equal to `checkpoint.epoch`, which the unit-test stores do not keep. + let epoch = get_current_epoch(state); + let mut total: Gwei = 0; + let mut increments: Vec = Vec::new(); + let balances = state + .iter_validators() + .map(|validator| { + // An effective balance is a whole number of increments no larger + // than `MAX_EFFECTIVE_BALANCE_ELECTRA`; the snapshot's exactness + // depends on it. + assert_eq!( + validator.effective_balance % preset::EFFECTIVE_BALANCE_INCREMENT, + 0, + "effective balance is a whole number of increments" + ); + increments.push( + u16::try_from(validator.effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT) + .expect("effective balance fits u16 increments"), + ); + let balance = vote_balance(validator, epoch); + total = total.saturating_add(balance); + balance + }) + .collect(); + JustifiedBalances::new( + checkpoint, + balances, + total.max(preset::EFFECTIVE_BALANCE_INCREMENT), + increments.into(), + ) } /// The effective balance of every non-equivocating, active, unslashed @@ -1342,6 +1628,38 @@ pub fn get_attestation_score( Ok(attestation_score) } +/// [`get_attestation_score`] read from the justified-balances snapshot instead +/// of the justified state: the same sum, since the snapshot holds a zero for +/// exactly the validators the state version skips (inactive, slashed), and +/// equivocators are dropped by the store. Reads no state, so the head path and +/// the reorg checks never need the justified checkpoint's. +fn snapshot_attestation_score( + store: &Store, + index: &HashMap, + root: Root, + balances: &JustifiedBalances, +) -> Result { + let block_slot = index + .get(&root) + .ok_or(Error::SpecAssert("root in store.blocks"))? + .0; + let mut attestation_score: Gwei = 0; + // Nothing that reads the store's own scratch may run inside the closure: + // see `for_each_non_equivocating_latest_message`. `get_ancestor` reads only + // `index`. + store.for_each_non_equivocating_latest_message(|validator_index, message| { + let balance = balances.get(validator_index); + if balance == 0 { + return; + } + if matches!(get_ancestor(index, message.root, block_slot), Ok(ancestor) if ancestor == root) + { + attestation_score = attestation_score.saturating_add(balance); + } + }); + Ok(attestation_score) +} + /// The LMD GHOST weight of `root`: [`get_attestation_score`] against the /// justified checkpoint's state, plus the proposer boost if it applies. /// @@ -1412,8 +1730,7 @@ pub fn compute_weights( config: &Config, ) -> Result> { let justified_checkpoint = store.beacon_justified_checkpoint(); - let state = checkpoint_state(store, &justified_checkpoint, config)?; - let current_epoch = get_current_epoch(&state); + let balances = justified_balances(store, config)?; // Keyed on the voted block itself; the fold below turns these into subtree // totals in place. @@ -1422,19 +1739,15 @@ pub fn compute_weights( // `for_each_non_equivocating_latest_message` for why asking it per voter // from in here would deadlock. store.for_each_non_equivocating_latest_message(|validator_index, message| { - // Not `get_active_validator_indices`: that allocates the whole active - // set (~2 million entries on mainnet) to answer a membership question, - // and an index past this state's registry is a validator that did not - // exist yet at the justified checkpoint, which is a skip rather than an - // error. - let Ok(validator) = state.validator(validator_index) else { - return; - }; - if validator.slashed || !is_active_validator(validator, current_epoch) { + // An index past the snapshot is a validator that did not exist at the + // justified checkpoint, and an inactive or slashed one is zero in it: + // both are a skip rather than an error. + let balance = balances.get(validator_index); + if balance == 0 { return; } let entry = weights.entry(message.root).or_default(); - *entry = entry.saturating_add(validator.effective_balance); + *entry = entry.saturating_add(balance); }); // Highest slot first: see above for why that is a topological order. @@ -1466,7 +1779,8 @@ pub fn compute_weights( let justified_slot = index .get(&justified_checkpoint.root) .map_or(0, |(slot, _)| *slot); - let proposer_score = get_proposer_score(store, config)?; + let proposer_score = + committee_fraction(balances.total_balance(), config.proposer_score_boost); let mut cursor = boost_root; while let Some((slot, parent_root)) = index.get(&cursor).copied() { let entry = weights.entry(cursor).or_default(); @@ -1948,9 +2262,11 @@ pub fn compute_node_weights( rules: ForkRules, ) -> Result { let tree = PayloadTree { store, rules }; - let justified_checkpoint = store.beacon_justified_checkpoint(); - let state = checkpoint_state(store, &justified_checkpoint, config)?; - let current_epoch = get_current_epoch(&state); + // The vote loop, the boost score and the gloas boost gate all read the + // justified checkpoint's snapshot, so a head walk reads no justified state: + // on a hit (the usual case, since import builds it) not even a lookup of + // one. The gate's committees come from the head and parent *block* states. + let balances = justified_balances(store, config)?; let bound = walk_bound(store, index); let in_window = |root: &Root| index.get(root).is_some_and(|&(slot, _)| slot >= bound); @@ -1960,19 +2276,18 @@ pub fn compute_node_weights( // from in here would deadlock, and a payload link is such a read. let mut votes: HashMap<(Root, Slot, bool), Gwei> = HashMap::new(); store.for_each_non_equivocating_latest_message(|validator_index, message| { - // Not `get_active_validator_indices`, for the reason - // `compute_weights` gives; a validator past this state's registry - // did not exist at the justified checkpoint, which is a skip. - let Ok(validator) = state.validator(validator_index) else { - return; - }; - if validator.slashed || !is_active_validator(validator, current_epoch) { + // An index past the snapshot is a validator that did not exist at the + // justified checkpoint, and an inactive or slashed one is zero in it: + // both are a skip rather than an error, as is a zero balance, which + // adds nothing either way. + let balance = balances.get(validator_index); + if balance == 0 { return; } let entry = votes .entry((message.root, message.slot, message.payload_present)) .or_default(); - *entry = entry.saturating_add(validator.effective_balance); + *entry = entry.saturating_add(balance); }); let mut weights = NodeWeights { @@ -2063,7 +2378,7 @@ pub fn compute_node_weights( config, committees, index, - &state, + &balances, |parent| { Ok(weights.raw(ForkChoiceNode { root: parent, @@ -2464,10 +2779,9 @@ pub fn is_head_weak( config: &Config, committees: &CommitteeCache, ) -> Result { - let justified_checkpoint = store.beacon_justified_checkpoint(); - let justified_state = checkpoint_state(store, &justified_checkpoint, config)?; + let balances = justified_balances(store, config)?; let index = store.block_index(); - let attestation_score = get_attestation_score(store, &index, head_root, &justified_state)?; + let attestation_score = snapshot_attestation_score(store, &index, head_root, &balances)?; let &(head_slot, _) = index .get(&head_root) .ok_or(Error::SpecAssert("head_root in store.blocks"))?; @@ -2477,7 +2791,7 @@ pub fn is_head_weak( head_slot, config, committees, - &justified_state, + &balances, attestation_score, ) } @@ -2498,11 +2812,11 @@ fn is_head_weak_with( head_slot: Slot, config: &Config, committees: &CommitteeCache, - justified_state: &BeaconState, + balances: &JustifiedBalances, attestation_score: Gwei, ) -> Result { let reorg_threshold = - calculate_committee_fraction(justified_state, config.reorg_head_weight_threshold)?; + committee_fraction(balances.total_balance(), config.reorg_head_weight_threshold); let mut head_weight = attestation_score; let head_state = store @@ -2514,8 +2828,11 @@ fn is_head_weak_with( for committee_index in 0..epoch_committees.committees_per_slot() { for &validator_index in epoch_committees.committee(head_slot, committee_index)? { if store.is_equivocating(validator_index) { - let validator = justified_state.validator(validator_index)?; - head_weight = head_weight.saturating_add(validator.effective_balance); + // The raw effective balance at the justified state: the + // snapshot's vote weight is zero for a slashed validator, and + // an equivocator often is. + head_weight = + head_weight.saturating_add(balances.effective_balance(validator_index)); } } } @@ -2532,15 +2849,16 @@ fn is_head_weak_with( /// its own purposes and this function's own lookup cannot disagree about /// which block that is. pub fn is_parent_strong(store: &Store, root: Root, config: &Config) -> Result { - let justified_checkpoint = store.beacon_justified_checkpoint(); - let justified_state = checkpoint_state(store, &justified_checkpoint, config)?; - let parent_threshold = - calculate_committee_fraction(&justified_state, config.reorg_parent_weight_threshold)?; + let balances = justified_balances(store, config)?; + let parent_threshold = committee_fraction( + balances.total_balance(), + config.reorg_parent_weight_threshold, + ); let (_, parent_root) = store .block_entry(&root) .ok_or(Error::SpecAssert("root in store.blocks"))?; let index = store.block_index(); - let parent_weight = get_attestation_score(store, &index, parent_root, &justified_state)?; + let parent_weight = snapshot_attestation_score(store, &index, parent_root, &balances)?; Ok(parent_weight > parent_threshold) } @@ -3316,16 +3634,15 @@ pub fn should_apply_proposer_boost( if store.proposer_boost_root().is_zero() { return Ok(false); } - let justified_checkpoint = store.beacon_justified_checkpoint(); - let justified_state = checkpoint_state(store, &justified_checkpoint, config)?; + let balances = justified_balances(store, config)?; let index = store.block_index(); should_apply_proposer_boost_with( store, config, committees, &index, - &justified_state, - |parent_root| get_attestation_score(store, &index, parent_root, &justified_state), + &balances, + |parent_root| snapshot_attestation_score(store, &index, parent_root, &balances), ) } @@ -3346,7 +3663,7 @@ fn should_apply_proposer_boost_with( config: &Config, committees: &CommitteeCache, index: &HashMap, - justified_state: &BeaconState, + balances: &JustifiedBalances, parent_score: impl FnOnce(Root) -> Result, ) -> Result { let proposer_boost_root = store.proposer_boost_root(); @@ -3375,7 +3692,7 @@ fn should_apply_proposer_boost_with( parent_slot, config, committees, - justified_state, + balances, parent_attestation_score, )? { return Ok(true); @@ -4496,7 +4813,6 @@ pub fn on_block( .get_state(&parent_root) .expect("get") .ok_or(Error::SpecAssert("block.parent_root in store.block_states"))?; - let mut state = (*parent_state).clone(); // [New in Gloas:EIP7732] If this block builds on its parent's full payload, // that payload must have been verified by `on_execution_payload_envelope`. @@ -4616,11 +4932,22 @@ pub fn on_block( stf::ExecutionEngine::valid() } }; + // + // An epoch-crossing block starts from the precomputed boundary state when + // one is cached, and from the parent's own post-state otherwise. Both reach + // the same post-state: the precomputed one is exactly what `process_slots` + // would have produced from the parent (a gloas parent included: a gloas + // block's post-state never holds its own payload, which only the child + // applies, after `process_slots`, so no second parent state exists). + // Either way `state` is an independent clone, so a failing block cannot + // corrupt a cached entry. // The store's own cache, so every block of this chain shares one entry per // epoch; see `ActiveBalanceCacheExt`. let active_balances = store.active_balance_cache(); - let transition = stf::state_transition( - &mut state, + let (mut state, transition) = transition_block( + store, + &parent_state, + parent_root, &signed_block, true, config, @@ -4671,6 +4998,11 @@ pub fn on_block( transition?; + // A block at its epoch's first slot is its own epoch-boundary block: its + // post-state is the checkpoint state. Cached at import so the tick that + // justifies it finds the balances built; see `justified_balances`. + cache_first_slot_balances(store, &state, block_root); + // Cache the state root in the latest block header. Sound because the // `true` above means `state_transition` checked it against the root it // computed; see `BeaconState::compute_state_root`. @@ -5489,7 +5821,12 @@ mod tests { /// [`anchor_pair`] over a registry of `count` validators, for the weight /// tests, which name a voter per validator index. fn anchor_pair_with(count: usize) -> (BeaconState, SignedBeaconBlock) { - let mut state = test_state::with_validators(count); + anchor_pair_from(test_state::with_validators(count)) + } + + /// [`anchor_pair`] over a caller-built `state`, for tests that need a + /// registry with slashed or inactive validators in it. + fn anchor_pair_from(mut state: BeaconState) -> (BeaconState, SignedBeaconBlock) { let parent_root = state.latest_block_header().parent_root; let mut signed = block(state.slot(), parent_root); @@ -5538,7 +5875,12 @@ mod tests { /// The anchor is what `checkpoint_state` resolves the justified checkpoint /// to, which is the one thing both weight functions need from a real store. fn anchored_store(count: usize) -> (Store, Root, Slot) { - let (anchor_state, anchor_block) = anchor_pair_with(count); + anchored_store_from(test_state::with_validators(count)) + } + + /// [`anchored_store`] over a caller-built state. + fn anchored_store_from(state: BeaconState) -> (Store, Root, Slot) { + let (anchor_state, anchor_block) = anchor_pair_from(state); let anchor_slot = anchor_state.slot(); let anchor_root = anchor_block.message_hash_tree_root(); let store = get_forkchoice_store( @@ -5551,13 +5893,30 @@ mod tests { (store, anchor_root, anchor_slot) } + /// A `count`-validator state (`count >= 9`) whose registry has every kind + /// of validator the weight paths must tell apart, at the state's own epoch: + /// validator 6 is slashed but active, 7 activates one epoch later, 8 exited + /// at this epoch, and the rest are plain active ones. + fn state_with_slashed_and_inactive_validators(count: usize) -> BeaconState { + let mut state = test_state::with_validators(count); + let epoch = get_current_epoch(&state); + state.validator_mut(6).expect("registered").slashed = true; + state.validator_mut(7).expect("registered").activation_epoch = epoch + 1; + state.validator_mut(8).expect("registered").exit_epoch = epoch; + state.apply_pending_mutations(); + state + } + /// `compute_weights` is the specification's `get_weight` for every root at /// once, so the two have to agree root by root: over a fork, over voters /// spread across both branches, and with the proposer boost applied. #[test] fn the_single_pass_weights_match_the_specifications_per_root_weight() { let config = Config::active(); - let (mut store, anchor_root, anchor_slot) = anchored_store(8); + // Validators 6 (slashed), 7 (not yet active) and 8 (exited) vote below + // and must weigh nothing; validator 40 is past the registry. + let (mut store, anchor_root, anchor_slot) = + anchored_store_from(state_with_slashed_and_inactive_validators(12)); // anchor -> a -> {b, c}: a fork whose two leaves split the vote, so a // wrong fold shows up as a leaf carrying its sibling's balance. @@ -5604,6 +5963,17 @@ mod tests { }, ); store.insert_equivocating_index(5); + for (validator_index, root) in [(6, c_root), (7, c_root), (8, c_root), (40, c_root)] { + store.set_latest_message( + validator_index, + LatestMessage { + epoch: 0, + slot: 0, + root, + payload_present: false, + }, + ); + } store.set_proposer_boost_root(b_root); let weights = compute_weights(&store, &index, &config).expect("the anchor state is there"); @@ -5619,6 +5989,355 @@ mod tests { weights[&b_root] > weights[&c_root], "three voters and the boost must outweigh one voter" ); + assert_eq!( + weights[&c_root], + preset::MAX_EFFECTIVE_BALANCE, + "only validator 3 counts on c: the slashed, inactive, exited and unknown voters weigh nothing" + ); + assert_eq!( + weights[&b_root], + 3 * preset::MAX_EFFECTIVE_BALANCE + + get_proposer_score(&store, &config).expect("the anchor state is there"), + ); + } + + /// The boost's committee weight divides the balance of the active, + /// unslashed validators (consensus-specs #5679), so it leaves out a + /// slashed validator that is still active, as well as one that is not + /// active yet or already exited. + #[test] + fn the_proposer_score_leaves_out_a_slashed_but_active_validator() { + let config = Config::active(); + let (store, _anchor_root, _anchor_slot) = + anchored_store_from(state_with_slashed_and_inactive_validators(12)); + + // 12 validators: 7 and 8 are inactive, and the active 6 is slashed. + let expected = committee_fraction( + 9 * preset::MAX_EFFECTIVE_BALANCE, + config.proposer_score_boost, + ); + assert_eq!( + get_proposer_score(&store, &config).expect("the anchor state is there"), + expected + ); + assert_ne!( + expected, + committee_fraction( + 10 * preset::MAX_EFFECTIVE_BALANCE, + config.proposer_score_boost + ), + "a total that kept the slashed validator would differ" + ); + // And the snapshot's total is the one the reorg thresholds divide. + let state = checkpoint_state(&store, &store.beacon_justified_checkpoint(), &config) + .expect("the anchor state is there"); + assert_eq!( + expected, + calculate_committee_fraction(&state, config.proposer_score_boost), + ); + } + + #[test] + fn building_the_snapshot_handles_activation_exit_slashing_and_an_empty_set() { + let mut state = test_state::with_validators(6); + let epoch = get_current_epoch(&state); + state.validator_mut(1).expect("registered").activation_epoch = epoch; + state.validator_mut(2).expect("registered").activation_epoch = epoch + 1; + state.validator_mut(3).expect("registered").exit_epoch = epoch; + state.validator_mut(4).expect("registered").slashed = true; + state.apply_pending_mutations(); + let checkpoint = Checkpoint { + epoch, + root: Root::repeat_byte(1), + }; + + let snapshot = build_justified_balances(checkpoint, &state); + let full = preset::MAX_EFFECTIVE_BALANCE; + // Activation at the epoch counts; exit at the epoch does not; a slashed + // but active validator weighs zero, as a voter and in the total. + assert_eq!( + [0, 1, 2, 3, 4, 5].map(|index| snapshot.get(index)), + [full, full, 0, 0, 0, full] + ); + assert_eq!(snapshot.total_balance(), 3 * full); + assert_ne!( + snapshot.total_balance(), + accessors::get_total_active_balance(&state).expect("total"), + "the state transition's total still counts the slashed validator" + ); + assert_eq!(snapshot.get(6), 0, "past the registry reads zero"); + assert_eq!(snapshot.checkpoint(), checkpoint); + + // No active validator at all: the total is floored like the spec's. + for index in 0..6 { + state.validator_mut(index).expect("registered").exit_epoch = epoch; + } + state.apply_pending_mutations(); + let empty = build_justified_balances(checkpoint, &state); + assert_eq!(empty.total_balance(), preset::EFFECTIVE_BALANCE_INCREMENT); + assert_eq!( + empty.total_balance(), + accessors::get_total_active_balance(&state).expect("total") + ); + assert_eq!(empty.get(0), 0); + } + + #[test] + fn a_new_justified_checkpoint_rebuilds_the_snapshot() { + let config = Config::active(); + let (mut store, anchor_root, _anchor_slot) = anchored_store(8); + + let first = justified_balances(&store, &config).expect("the anchor state is there"); + let again = justified_balances(&store, &config).expect("cached"); + assert!( + Arc::ptr_eq(&first, &again), + "same checkpoint, same snapshot" + ); + assert_eq!(first.get(0), preset::MAX_EFFECTIVE_BALANCE); + + // A later justified checkpoint over a state with different balances, + // planted where `checkpoint_state` finds it. + let next = Checkpoint { + epoch: first.checkpoint().epoch + 1, + root: Root::repeat_byte(0x77), + }; + let mut state = test_state::with_validators(8); + *state.slot_mut() = compute_start_slot_at_epoch(next.epoch); + state + .validator_mut(0) + .expect("registered") + .effective_balance = 5 * preset::EFFECTIVE_BALANCE_INCREMENT; + state.validator_mut(1).expect("registered").slashed = true; + state.apply_pending_mutations(); + store.cache_state( + CacheKey::CheckpointState { + epoch: next.epoch, + root: next.root, + }, + Arc::new(state), + ); + let finalized = store.beacon_finalized_checkpoint(); + update_checkpoints(&mut store, next, finalized); + assert_ne!(store.beacon_justified_checkpoint().root, anchor_root); + + let rebuilt = justified_balances(&store, &config).expect("planted state"); + assert!(!Arc::ptr_eq(&first, &rebuilt)); + assert_eq!(rebuilt.checkpoint(), next); + assert_eq!(rebuilt.get(0), 5 * preset::EFFECTIVE_BALANCE_INCREMENT); + assert_eq!(rebuilt.get(1), 0, "slashed in the new state"); + assert_eq!(rebuilt.get(2), preset::MAX_EFFECTIVE_BALANCE); + } + + /// A checkpoint whose state is nowhere in the store, with the snapshot for + /// it already cached, as block import leaves it. Every head-path reader + /// must answer from the snapshot: any `checkpoint_state` for it fails. + fn store_with_only_a_justified_snapshot( + state: &BeaconState, + ) -> (Store, Checkpoint, Root, CommitteeCache) { + let (mut store, anchor_root, _) = anchored_store_from(state.clone()); + let next = Checkpoint { + epoch: store.beacon_justified_checkpoint().epoch + 1, + root: Root::repeat_byte(0x77), + }; + let finalized = store.beacon_finalized_checkpoint(); + update_checkpoints(&mut store, next, finalized); + store.insert_justified_balances(Arc::new(build_justified_balances(next, state))); + (store, next, anchor_root, CommitteeCache::default()) + } + + #[test] + fn a_snapshot_hit_reads_no_justified_state_on_any_head_path() { + let config = Config::active(); + let state = state_with_slashed_and_inactive_validators(12); + let (store, next, anchor_root, committees) = store_with_only_a_justified_snapshot(&state); + assert!( + checkpoint_state(&store, &next, &config).is_err(), + "the justified state is not anywhere to be found" + ); + + let snapshot = justified_balances(&store, &config).expect("a hit"); + assert_eq!(snapshot.checkpoint(), next); + let index = store.block_index(); + compute_weights(&store, &index, &config).expect("pre-gloas weights"); + for rules in [ForkRules::PreGloas, ForkRules::Gloas] { + compute_node_weights(&store, &index, &config, &committees, rules) + .unwrap_or_else(|err| panic!("{rules:?} head walk: {err:?}")); + } + get_proposer_score(&store, &config).expect("boost score"); + is_head_weak(&store, anchor_root, &config, &committees).expect("is_head_weak"); + assert!(!should_apply_proposer_boost(&store, &config, &committees).expect("no boost root")); + } + + /// The snapshot keeps what `is_head_weak` needs of a slashed equivocator + /// (its raw effective balance, though its vote weighs zero), and the + /// snapshot-based decision equals the specification's state-based one. + #[test] + fn a_slashed_equivocator_still_counts_toward_head_weakness_from_the_snapshot() { + let config = Config::active(); + let mut state = test_state::with_validators(preset::SLOTS_PER_EPOCH as usize); + for index in 0..preset::SLOTS_PER_EPOCH { + state.validator_mut(index).expect("registered").slashed = true; + } + state.apply_pending_mutations(); + let (mut store, anchor_root, anchor_slot) = anchored_store_from(state); + let committees = CommitteeCache::default(); + + // The specification's `is_head_weak`, read off the justified state. + let reference = |store: &Store| -> bool { + let justified_state = + checkpoint_state(store, &store.beacon_justified_checkpoint(), &config) + .expect("justified state"); + let index = store.block_index(); + let mut weight = + get_attestation_score(store, &index, anchor_root, &justified_state).expect("score"); + let head_state = store.get_state(&anchor_root).expect("get").expect("state"); + let epoch_committees = + committees.committees(&head_state, compute_epoch_at_slot(anchor_slot)); + for committee_index in 0..epoch_committees.committees_per_slot() { + for &validator in epoch_committees + .committee(anchor_slot, committee_index) + .expect("committee") + { + if store.is_equivocating(validator) { + weight += justified_state + .validator(validator) + .expect("registered") + .effective_balance; + } + } + } + let threshold = + calculate_committee_fraction(&justified_state, config.reorg_head_weight_threshold); + weight < threshold + }; + + assert!(is_head_weak(&store, anchor_root, &config, &committees).unwrap()); + assert_eq!( + is_head_weak(&store, anchor_root, &config, &committees).unwrap(), + reference(&store) + ); + + let head_state = store.get_state(&anchor_root).expect("get").expect("state"); + let equivocator = committees + .committees(&head_state, compute_epoch_at_slot(anchor_slot)) + .committee(anchor_slot, 0) + .expect("committee 0")[0]; + store.insert_equivocating_index(equivocator); + + let snapshot = justified_balances(&store, &config).expect("snapshot"); + assert_eq!(snapshot.get(equivocator), 0, "slashed: no vote weight"); + assert_eq!( + snapshot.effective_balance(equivocator), + preset::MAX_EFFECTIVE_BALANCE, + "but its effective balance is still kept" + ); + assert!(!is_head_weak(&store, anchor_root, &config, &committees).unwrap()); + assert_eq!( + is_head_weak(&store, anchor_root, &config, &committees).unwrap(), + reference(&store) + ); + } + + // ---- balances built at import ---- + + /// Importing past empty first slots makes `(epoch, parent)` an + /// epoch-boundary checkpoint, and the balances cached for it are what + /// `checkpoint_state` for that key yields, on a precompute miss and on a + /// hit alike, with the block's post-state unchanged by the split. + #[test] + fn a_crossing_import_caches_the_boundary_balances_for_the_parent() { + let config = Config::mainnet(); + let parent = last_slot_parent(); + let parent_root = Root::repeat_byte(0x42); + let epoch = 2; + let boundary = Checkpoint { + epoch, + root: parent_root, + }; + let expected = build_justified_balances( + boundary, + &advance_to_epoch_start(&parent, epoch, &config).expect("boundary state"), + ); + let first_slot = compute_start_slot_at_epoch(epoch); + + for slot in [first_slot + 1, first_slot + 3] { + let (block, committed) = block_on(&parent, slot); + let miss_store = empty_store(); + let hit_store = store_with_precompute(&parent, parent_root, epoch); + for (store, label) in [(&miss_store, "miss"), (&hit_store, "hit")] { + assert!(store.justified_balances(&boundary).is_none()); + let root = post_state_root(store, &parent, parent_root, &block); + assert_eq!(root, committed, "slot {slot} {label}: state root verifies"); + let cached = store + .justified_balances(&boundary) + .unwrap_or_else(|| panic!("slot {slot} {label}: boundary balances cached")); + assert_eq!(*cached, expected, "slot {slot} {label}"); + } + } + } + + /// A block exactly at the epoch's first slot is its own boundary block: + /// nothing is cached for its parent, and its post-state's balances are + /// cached under `(epoch, block_root)`, equal to what `checkpoint_state` + /// then derives. + #[test] + fn a_first_slot_import_caches_balances_from_its_own_post_state() { + let config = Config::mainnet(); + let parent = last_slot_parent(); + let parent_root = Root::repeat_byte(0x42); + let block_root = Root::repeat_byte(0x99); + let epoch = 2; + let first_slot = compute_start_slot_at_epoch(epoch); + let (signed, _) = block_on(&parent, first_slot); + + let mut store = empty_store(); + let (mut post, outcome) = transition_block( + &store, + &parent, + parent_root, + &signed, + false, + &config, + &stf::ExecutionEngine::valid(), + &CommitteeCache::default(), + &ActiveBalanceCache::default(), + ); + outcome.expect("applies"); + post.apply_pending_mutations(); + assert!( + store + .justified_balances(&Checkpoint { + epoch, + root: parent_root + }) + .is_none(), + "the parent is not this epoch's boundary block" + ); + + cache_first_slot_balances(&store, &post, block_root); + let key = Checkpoint { + epoch, + root: block_root, + }; + let cached = store.justified_balances(&key).expect("cached"); + + store + .insert_signed_block(block_root, block(first_slot, parent_root)) + .expect("insert block"); + store.insert_state(block_root, post).expect("insert state"); + let derived = checkpoint_state(&store, &key, &config).expect("checkpoint state"); + assert_eq!(*cached, build_justified_balances(key, &derived)); + + // Any other slot of the epoch is not a boundary. + let other = Root::repeat_byte(0x55); + let mut later = (*derived).clone(); + stf::process_slots(&mut later, first_slot + 1, &config).expect("advance"); + cache_first_slot_balances(&store, &later, other); + assert!( + store + .justified_balances(&Checkpoint { epoch, root: other }) + .is_none() + ); } /// The failure a live mainnet follower hit: the beacon arm of @@ -6132,6 +6851,8 @@ mod tests { let root = block.message_hash_tree_root(); let slot = block.slot(); *post_state.slot_mut() = slot; + // The state is keyed by its block's slot, which is the header's. + post_state.latest_block_header_mut().slot = slot; store.insert_signed_block(root, block).unwrap(); store.insert_state(root, post_state).unwrap(); compute_pulled_up_tip(store, root, slot, config).expect("pulled-up tip"); @@ -8590,6 +9311,8 @@ mod tests { unreachable!("with_validators_at(Gloas) builds a gloas state"); }; inner.slot = 1; + // The state is keyed by its block's slot, which is the header's. + inner.latest_block_header.slot = 1; let mut committee = vec![0; preset::PTC_SIZE]; committee[2] = 3; let window_index = (preset::SLOTS_PER_EPOCH + 1) as usize; @@ -9563,4 +10286,244 @@ mod tests { ); assert_eq!(store.payload_link(&root), Some(link)); } + + // ---- epoch precompute ---- + + use crate::beacon::block_production::{ + BlockInputs, advance_to_slot, assemble_block, empty_sync_aggregate, payload_inputs, + }; + use crate::beacon::containers::electra; + use crate::beacon::helpers::accessors::{get_beacon_proposer_index, get_domain}; + use crate::beacon::helpers::test_state::{sign_for, with_signing_validators_at}; + use crate::beacon::primitives::Bytes32; + use ethlambda_types::beacon::containers::deneb::ExecutionPayload; + use ethlambda_types::beacon::containers::electra::{BeaconBlockBody, ExecutionRequests}; + + /// A fulu state at the last slot of epoch 1, the parent of every + /// epoch-crossing block below. + fn last_slot_parent() -> BeaconState { + let mut state = with_signing_validators_at(crate::beacon::ForkName::Fulu, 64); + let lookahead = + crate::beacon::helpers::fulu::initialize_proposer_lookahead(&state).expect("lookahead"); + let sync_committee = + crate::beacon::helpers::altair::get_next_sync_committee(&state).expect("committee"); + let BeaconState::Fulu(inner) = &mut state else { + unreachable!("built as fulu") + }; + inner.proposer_lookahead = lookahead.try_into().expect("lookahead length"); + inner.current_sync_committee = sync_committee.clone(); + inner.next_sync_committee = sync_committee; + let last_slot = 2 * preset::SLOTS_PER_EPOCH - 1; + advance_to_slot(&state, last_slot, &Config::mainnet()).expect("advance") + } + + /// A block at `slot` built on `parent` the way a proposer would, and the + /// post-state root it commits to. + fn block_on(parent: &BeaconState, slot: Slot) -> (SignedBeaconBlock, Root) { + let config = Config::mainnet(); + let advanced = advance_to_slot(parent, slot, &config).expect("advance"); + let proposer = get_beacon_proposer_index(&advanced).expect("proposer"); + let epoch = get_current_epoch(&advanced); + let domain = get_domain(&advanced, constants::DOMAIN_RANDAO, Some(epoch)); + let randao_reveal: BlsSignature = sign_for( + proposer as usize, + compute_signing_root(epoch.hash_tree_root(), domain), + ); + let payload = payload_inputs(&advanced, &config).expect("payload inputs"); + let inputs = BlockInputs { + randao_reveal, + graffiti: Bytes32::ZERO, + attestations: Vec::new(), + operations: Default::default(), + execution_payload: ExecutionPayload { + parent_hash: payload.parent_hash, + prev_randao: payload.prev_randao, + timestamp: payload.timestamp, + withdrawals: payload.withdrawals.try_into().expect("withdrawals"), + ..BeaconBlockBody::empty().execution_payload + }, + blob_kzg_commitments: Vec::new(), + execution_requests: ExecutionRequests::default(), + sync_aggregate: empty_sync_aggregate(), + }; + let message = assemble_block( + &advanced, + inputs, + &config, + crate::beacon::stf::block::BlockProcessingOptions::default(), + ) + .expect("assemble"); + let state_root = message.state_root; + let signed = SignedBeaconBlock::Fulu(electra::SignedBeaconBlock { + message, + signature: BlsSignature::default(), + }); + (signed, state_root) + } + + fn post_state_root( + store: &Store, + parent: &BeaconState, + parent_root: Root, + block: &SignedBeaconBlock, + ) -> Root { + let (mut state, outcome) = transition_block( + store, + parent, + parent_root, + block, + false, + &Config::mainnet(), + &stf::ExecutionEngine::valid(), + &CommitteeCache::default(), + &ActiveBalanceCache::default(), + ); + outcome.expect("the block applies"); + state.apply_pending_mutations(); + state.hash_tree_root() + } + + fn store_with_precompute(parent: &BeaconState, parent_root: Root, epoch: Epoch) -> Store { + let store = empty_store(); + let precomputed = + advance_to_epoch_start(parent, epoch, &Config::mainnet()).expect("precompute"); + store.cache_state( + CacheKey::CheckpointState { + epoch, + root: parent_root, + }, + Arc::new(precomputed), + ); + store + } + + /// Resuming from the precomputed state is not a different transition: the + /// post-state is the one the inline path commits to, for a block on the + /// boundary and for one past a skipped first slot. + #[test] + fn resuming_from_a_precomputed_state_gives_the_inline_post_state() { + let parent = last_slot_parent(); + let parent_root = Root::repeat_byte(0x42); + let epoch = 2; + let first_slot = compute_start_slot_at_epoch(epoch); + + for slot in [first_slot, first_slot + 1, first_slot + 3] { + let (block, committed) = block_on(&parent, slot); + let hit_store = store_with_precompute(&parent, parent_root, epoch); + assert!( + precomputed_epoch_state(&hit_store, parent.slot(), parent_root, slot).is_some() + ); + + let inline = post_state_root(&empty_store(), &parent, parent_root, &block); + let resumed = post_state_root(&hit_store, &parent, parent_root, &block); + assert_eq!(inline, committed, "slot {slot}: inline matches the block"); + assert_eq!(resumed, inline, "slot {slot}: resumed matches inline"); + } + } + + #[test] + fn only_an_epoch_crossing_import_looks_for_a_precomputed_state() { + let parent = last_slot_parent(); + let parent_root = Root::repeat_byte(0x42); + let store = store_with_precompute(&parent, parent_root, 2); + let first_slot = compute_start_slot_at_epoch(2); + + // Crosses: parent before the boundary, block at or after it. + assert!(precomputed_epoch_state(&store, parent.slot(), parent_root, first_slot).is_some()); + // Same epoch as its parent: nothing to resume. + assert!(precomputed_epoch_state(&store, first_slot, parent_root, first_slot + 1).is_none()); + // A different parent has no entry. + assert!( + precomputed_epoch_state(&store, parent.slot(), Root::repeat_byte(1), first_slot) + .is_none() + ); + } + + /// The worker's result is what `checkpoint_state` derives for the same + /// `(epoch, root)`, which is why one cache key can serve both. + #[test] + fn the_precompute_equals_checkpoint_state_for_the_same_key() { + let config = Config::mainnet(); + let parent = last_slot_parent(); + let root = Root::repeat_byte(0x42); + let mut store = empty_store(); + store + .insert_signed_block(root, block(parent.slot(), Root::ZERO)) + .expect("insert block"); + store + .insert_state(root, parent.clone()) + .expect("insert state"); + let epoch = 2; + + let worker = advance_to_epoch_start(&parent, epoch, &config).expect("precompute"); + let derived = + checkpoint_state(&store, &Checkpoint { epoch, root }, &config).expect("checkpoint"); + + assert_eq!(worker.slot(), compute_start_slot_at_epoch(epoch)); + assert_eq!(worker.slot(), derived.slot()); + assert_eq!(worker.hash_tree_root(), derived.hash_tree_root()); + } + + /// A gloas state at the last slot of epoch 1, the parent of the gloas + /// epoch-crossing cases below. + fn gloas_last_slot_parent() -> BeaconState { + let state = test_state::with_validators_at(crate::beacon::ForkName::Gloas, 64); + let last_slot = 2 * preset::SLOTS_PER_EPOCH - 1; + advance_to_slot(&state, last_slot, &Config::mainnet()).expect("advance") + } + + /// A gloas block's post-state never holds its own payload (the child applies + /// it inside `process_block`, after `process_slots`), and the store keeps + /// one state per root, so the precompute starts from exactly the state an + /// import of a child would. Checked three ways for a gloas parent: the + /// worker's result is `checkpoint_state`'s for the same key, an import + /// finds it under that key, and advancing past a skipped first slot from it + /// gives the state a plain `process_slots` from the parent gives. + #[test] + fn a_gloas_precompute_starts_from_the_state_an_import_would() { + let config = Config::mainnet(); + let parent = gloas_last_slot_parent(); + let root = Root::repeat_byte(0x42); + let mut store = empty_store(); + store + .insert_signed_block(root, block(parent.slot(), Root::ZERO)) + .expect("insert block"); + store + .insert_state(root, parent.clone()) + .expect("insert state"); + let epoch = 2; + let first_slot = compute_start_slot_at_epoch(epoch); + + let worker = advance_to_epoch_start(&parent, epoch, &config).expect("precompute"); + let derived = + checkpoint_state(&store, &Checkpoint { epoch, root }, &config).expect("checkpoint"); + assert_eq!(worker.fork_name(), crate::beacon::ForkName::Gloas); + assert_eq!(worker.hash_tree_root(), derived.hash_tree_root()); + + store.cache_state(CacheKey::CheckpointState { epoch, root }, Arc::new(worker)); + let stored = store + .get_state(&root) + .expect("get") + .expect("the parent's own post-state"); + assert!( + precomputed_epoch_state(&store, stored.slot(), root, first_slot).is_some(), + "an import of a child of `root` finds the entry" + ); + + for slot in [first_slot + 1, first_slot + 3] { + let mut resumed = (*precomputed_epoch_state(&store, stored.slot(), root, slot) + .expect("cached")) + .clone(); + stf::process_slots(&mut resumed, slot, &config).expect("advance the rest"); + let mut inline = (*stored).clone(); + stf::process_slots(&mut inline, slot, &config).expect("advance inline"); + resumed.apply_pending_mutations(); + inline.apply_pending_mutations(); + assert_eq!( + resumed.hash_tree_root(), + inline.hash_tree_root(), + "slot {slot}" + ); + } + } } diff --git a/crates/blockchain/state_transition/src/beacon/gossip/envelope.rs b/crates/blockchain/state_transition/src/beacon/gossip/envelope.rs index 6fa257b64..8f5d6cfb7 100644 --- a/crates/blockchain/state_transition/src/beacon/gossip/envelope.rs +++ b/crates/blockchain/state_transition/src/beacon/gossip/envelope.rs @@ -331,7 +331,14 @@ mod tests { store .insert_pending_block(BLOCK_ROOT, SignedBeaconBlock::Gloas(block)) .expect("insert the block"); - let state = fulu_parent(3); + // The block's post-state names the block in its header, and the store + // keys a state's row by that header's slot: a state for the slot-5 + // block must say slot 5 there, or a read after the writer flushes it + // looks under another key. + let mut state = fulu_parent(3); + if let crate::beacon::containers::BeaconState::Fulu(inner) = &mut state { + inner.latest_block_header.slot = 5; + } store .insert_state(BLOCK_ROOT, state.clone()) .expect("insert the state"); diff --git a/crates/blockchain/state_transition/src/beacon/gossip/proposer_preferences.rs b/crates/blockchain/state_transition/src/beacon/gossip/proposer_preferences.rs index 21abad0fa..f278ea62e 100644 --- a/crates/blockchain/state_transition/src/beacon/gossip/proposer_preferences.rs +++ b/crates/blockchain/state_transition/src/beacon/gossip/proposer_preferences.rs @@ -301,6 +301,9 @@ mod tests { .expect("link the head to the dependent block"); let mut dependent_state = scene.state.clone(); *dependent_state.slot_mut() = 0; + // States are stored under their block's slot, which the writer reads + // from the state's own header; the block above sits at slot 0. + dependent_state.latest_block_header_mut().slot = 0; scene .store .insert_state(DEPENDENT, dependent_state) diff --git a/crates/blockchain/state_transition/src/beacon/helpers/accessors.rs b/crates/blockchain/state_transition/src/beacon/helpers/accessors.rs index 1b33d655b..a4393232a 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/accessors.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/accessors.rs @@ -131,8 +131,10 @@ pub fn get_seed(state: &BeaconState, epoch: Epoch, domain_type: DomainType) -> B /// split out so [`build_epoch_committees`] can share one /// [`get_active_validator_indices`] scan between this and its own committee /// derivation, rather than [`get_committee_count_per_slot`] repeating the scan -/// the caller already did to get `active_count` in the first place. -fn committee_count_per_slot(active_count: u64) -> u64 { +/// the caller already did to get `active_count` in the first place. Public +/// for p2p's gossipsub scoring, which sizes expected aggregate traffic from +/// a validator count rather than from a state. +pub fn committee_count_per_slot(active_count: u64) -> u64 { let ideal = active_count / preset::SLOTS_PER_EPOCH / preset::TARGET_COMMITTEE_SIZE; ideal.clamp(1, preset::MAX_COMMITTEES_PER_SLOT as u64) } 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 7ac49fee4..f33f7641a 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/altair.rs @@ -104,7 +104,7 @@ pub(super) fn weigh_with_totals( /// epoch's participation and genesis has none. /// /// Shared by every fork from altair on, gloas included: reading -/// [`crate::beacon::containers::BeaconState::inactivity_scores_mut`] rather than +/// [`crate::beacon::containers::BeaconState::inactivity_score_mut`] rather than /// [`crate::beacon::containers::BeaconState::altair_validator_lists_mut`] is what /// makes that so, since this only ever writes a score in place and never /// grows or replaces the list, and that narrower accessor extends to a gloas @@ -129,26 +129,40 @@ fn update_inactivity_scores( ) -> Result<()> { let leaking = is_in_inactivity_leak(state)?; - let inactivity_scores = state.inactivity_scores_mut()?; - let score_count = inactivity_scores.len(); - for (index, (flags, _)) in summary.iter().enumerate() { - if !flags.is_eligible() { - continue; - } - let score = inactivity_scores - .get_mut(index) - .ok_or(Error::IndexOutOfBounds { + // Decided over an in-order walk of the scores and written afterwards: a + // tree-backed list is read with an iterator (one descent per element would + // dominate), and only a score that changes is written, since outside a + // leak almost every score stays zero and a write of an equal value would + // still unshare its leaf from the parent state's. + let changes = { + let (_, _, inactivity_scores) = state.altair_validator_lists()?; + let score_count = inactivity_scores.len(); + let mut score_iter = inactivity_scores.iter(); + let mut changes: Vec<(usize, u64)> = Vec::new(); + for (index, (flags, _)) in summary.iter().enumerate() { + // Advanced for ineligible validators too, to stay in step with `index`; + // a score list shorter than the registry is an error only at the first + // eligible validator without one. + let score = score_iter.next().copied(); + if !flags.is_eligible() { + continue; + } + let score = score.ok_or(Error::IndexOutOfBounds { index, len: score_count, })?; - let updated = next_inactivity_score(*score, flags, leaking, config)?; - - if updated != *score { - *score = updated; + let updated = next_inactivity_score(score, flags, leaking, config)?; + if updated != score { + changes.push((index, updated)); + } } - } + changes + }; + for (index, updated) in changes { + *state.inactivity_score_mut(index)? = updated; + } Ok(()) } @@ -515,7 +529,7 @@ mod tests { process_inactivity_updates(&mut state, &Config::mainnet()).unwrap(); let (_, _, scores) = state.altair_validator_lists().unwrap(); - assert_eq!(scores, before); + assert_eq!(scores.to_vec(), before); } #[test] @@ -570,6 +584,58 @@ mod tests { assert_eq!(scores[0], 0); } + /// Outside a leak a missed epoch adds the bias and recovery takes it back + /// to zero, so every score ends where it started. The pass must then + /// leave the list untouched, not just equal: a write of an equal value + /// would still unshare the tree from the parent state's. + #[test] + fn inactivity_updates_that_change_nothing_leave_the_tree_shared() { + let mut state = altair_state_with_validators(4); + state.apply_pending_mutations(); + let before_state = state.clone(); + let (_, _, before) = before_state.altair_validator_lists().unwrap(); + + process_inactivity_updates(&mut state, &Config::mainnet()).unwrap(); + + let (_, _, scores) = state.altair_validator_lists().unwrap(); + assert!(!scores.has_pending_updates(), "no write was buffered"); + assert!(scores.ptr_eq(&before)); + assert_eq!(scores.to_vec(), vec![0; 4]); + } + + #[test] + fn inactivity_updates_buffer_only_the_scores_that_change() { + let mut state = altair_state_with_validators(4); + { + let (previous_epoch_participation, _, scores) = + state.altair_validator_lists_mut().unwrap(); + scores[2] = 3; + // Validator 2 participates, so its score falls to zero; the rest + // stay at zero. + previous_epoch_participation[2] = add_flag(0, constants::TIMELY_TARGET_FLAG_INDEX); + } + state.apply_pending_mutations(); + + process_inactivity_updates(&mut state, &Config::mainnet()).unwrap(); + + let (_, _, scores) = state.altair_validator_lists().unwrap(); + assert!(scores.has_pending_updates()); + assert_eq!(scores.to_vec(), vec![0; 4]); + } + + #[test] + fn inactivity_updates_still_reject_an_overflowing_score() { + let mut state = altair_state_with_validators(4); + { + let (_, _, scores) = state.altair_validator_lists_mut().unwrap(); + scores[1] = u64::MAX; + } + + let result = process_inactivity_updates(&mut state, &Config::mainnet()); + + assert!(matches!(result, Err(Error::ArithmeticOverflow(_)))); + } + // ----------------------------------------------------------------------- // process_rewards_and_penalties // ----------------------------------------------------------------------- diff --git a/crates/blockchain/state_transition/src/beacon/stf/epoch/gloas.rs b/crates/blockchain/state_transition/src/beacon/stf/epoch/gloas.rs index abb21585b..c07dddf8b 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/epoch/gloas.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/epoch/gloas.rs @@ -31,7 +31,7 @@ //! [`super::altair::process_rewards_and_penalties`]) reach //! `previous_epoch_participation`/`current_epoch_participation`/`inactivity_scores` //! as plain slices, through [`crate::beacon::containers::BeaconState::altair_validator_lists`] -//! and [`crate::beacon::containers::BeaconState::inactivity_scores_mut`]: both +//! and [`crate::beacon::containers::BeaconState::inactivity_score_mut`]: both //! `SszList` and `ProgressiveList` `Deref` to a plain `[T]`. //! - **[`super::electra::process_pending_consolidations`]** takes the queue //! as a `Vec` and writes it back, through diff --git a/crates/blockchain/state_transition/src/beacon/stf/mod.rs b/crates/blockchain/state_transition/src/beacon/stf/mod.rs index d5b9dfbf0..3dba6eef8 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/mod.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/mod.rs @@ -137,7 +137,35 @@ pub fn state_transition( active_balances: &ActiveBalanceCache, ) -> Result<()> { process_slots(state, signed_block.slot(), config)?; + apply_block( + state, + signed_block, + validate_result, + config, + engine, + committees, + active_balances, + ) +} +/// [`state_transition`] from the point where `state` is already at the block's +/// own slot: everything after `process_slots`. +/// +/// For a caller that advanced the state itself (fork choice resuming from a +/// precomputed epoch-boundary state). `process_slots` rejects a target equal to +/// the current slot, so such a state cannot go through [`state_transition`] +/// when the block sits exactly on the boundary. A state at the wrong slot is +/// still refused: the block header check in `process_block` compares the +/// block's slot with the state's. +pub fn apply_block( + state: &mut BeaconState, + signed_block: &containers::SignedBeaconBlock, + validate_result: bool, + config: &Config, + engine: &ExecutionEngine, + committees: &CommitteeCache, + active_balances: &ActiveBalanceCache, +) -> Result<()> { // After `process_slots`, never before it. A block proposed at the first slot // of a fork's activation epoch is the *post*-fork shape while the state // arriving here is still the pre-fork one, which is exactly the case a fork @@ -357,6 +385,7 @@ mod tests { use super::*; use crate::beacon::fork::ForkName; use crate::beacon::helpers::test_state; + use crate::beacon::primitives::Root; #[test] fn process_slot_unsets_next_slots_payload_availability_on_gloas() { @@ -433,7 +462,11 @@ mod tests { process_slot(&mut state).unwrap(); - assert!(!state.has_pending_mutations()); + assert!(!state.registry_has_pending_updates()); + // Its own two roots writes stay buffered until the next flush, which + // the following slot (or `process_slots`' last step) performs. + assert!(state.state_roots().has_pending_updates()); + assert!(state.block_roots().has_pending_updates()); } /// Epoch processing (`process_rewards_and_penalties` here) writes every @@ -452,4 +485,112 @@ mod tests { assert!(!state.has_pending_mutations()); } + + #[test] + fn process_slots_flushes_the_roots_it_wrote_in_its_last_slot() { + for fork in BEACON_FORKS { + let mut state = test_state::with_validators_at(fork, 4); + let target_slot = state.slot() + 1; + + process_slots(&mut state, target_slot, &Config::mainnet()).unwrap(); + + assert!(!state.has_pending_mutations(), "{fork:?}"); + } + } + + const BEACON_FORKS: [ForkName; 8] = [ + ForkName::Phase0, + ForkName::Altair, + ForkName::Bellatrix, + ForkName::Capella, + ForkName::Deneb, + ForkName::Electra, + ForkName::Fulu, + ForkName::Gloas, + ]; + + /// Writes one element of the field a case names, or answers `false` if the + /// fork does not have it. + type Write = fn(&mut BeaconState) -> bool; + + fn push_historical_summary(state: &mut BeaconState) -> bool { + let summary = containers::HistoricalSummary::default(); + match state { + BeaconState::Capella(s) => s.historical_summaries.push(summary).unwrap(), + BeaconState::Deneb(s) => s.historical_summaries.push(summary).unwrap(), + BeaconState::Electra(s) => s.historical_summaries.push(summary).unwrap(), + BeaconState::Fulu(s) => s.historical_summaries.push(summary).unwrap(), + BeaconState::Gloas(s) => s.historical_summaries.push(summary).unwrap(), + _ => return false, + } + true + } + + /// One write to every tree-backed field of every fork: the flush and the + /// pending check must both see all of them, since both come from one + /// field list. + #[test] + fn every_tree_field_of_every_fork_is_flushed_and_checked() { + let cases: [(&str, Write); 11] = [ + ("validators", |s| { + s.validator_mut(0).unwrap().effective_balance -= 1; + true + }), + ("balances", |s| { + *s.balance_mut(0).unwrap() += 1; + true + }), + ("block_roots", |s| { + s.block_roots_mut()[0] = Root::repeat_byte(1); + true + }), + ("state_roots", |s| { + s.state_roots_mut()[0] = Root::repeat_byte(1); + true + }), + ("historical_roots", |s| { + s.historical_roots_mut().push(Root::repeat_byte(1)).unwrap(); + true + }), + ("eth1_data_votes", |s| { + s.eth1_data_votes_mut().push(Default::default()).unwrap(); + true + }), + ("randao_mixes", |s| { + s.randao_mixes_mut()[0] = Root::repeat_byte(1); + true + }), + ("slashings", |s| { + s.slashings_mut()[0] += 1; + true + }), + // Gloas keeps its scores in a flat progressive list that buffers + // nothing, so a write there leaves nothing pending. + ("inactivity_scores", |s| { + let buffered = !matches!(s, BeaconState::Gloas(_)); + match s.inactivity_score_mut(0) { + Ok(score) => { + *score += 1; + buffered + } + Err(_) => false, + } + }), + ("historical_summaries", push_historical_summary), + // A read-only pass writes nothing and must leave nothing pending. + ("none", |_| false), + ]; + + for fork in BEACON_FORKS { + let mut state = test_state::with_validators_at(fork, 4); + assert!(!state.has_pending_mutations(), "{fork:?} starts flushed"); + + for (name, write) in cases { + let wrote = write(&mut state); + assert_eq!(state.has_pending_mutations(), wrote, "{fork:?} {name}"); + state.apply_pending_mutations(); + assert!(!state.has_pending_mutations(), "{fork:?} {name} flushed"); + } + } + } } diff --git a/crates/blockchain/state_transition/src/beacon/upgrade.rs b/crates/blockchain/state_transition/src/beacon/upgrade.rs index dbcaa52b0..311d3637a 100644 --- a/crates/blockchain/state_transition/src/beacon/upgrade.rs +++ b/crates/blockchain/state_transition/src/beacon/upgrade.rs @@ -1411,8 +1411,8 @@ mod tests { // place rather than clearing it, since `historical_summaries` is // where new history accumulates from here on. assert_eq!( - post.historical_roots.into_inner(), - bellatrix_state.historical_roots.into_inner() + post.historical_roots.to_vec(), + bellatrix_state.historical_roots.to_vec() ); assert_eq!(post.next_withdrawal_index, 0); assert_eq!(post.next_withdrawal_validator_index, 0); diff --git a/crates/blockchain/state_transition/src/metrics.rs b/crates/blockchain/state_transition/src/metrics.rs index c9e1b8c7e..63c029166 100644 --- a/crates/blockchain/state_transition/src/metrics.rs +++ b/crates/blockchain/state_transition/src/metrics.rs @@ -80,6 +80,88 @@ pub fn inc_total_active_balance_lookups(result: &str) { .inc(); } +static LEAN_BEACON_JUSTIFIED_BALANCES_LOOKUPS_TOTAL: LazyLock = LazyLock::new( + || { + register_int_counter_vec!( + "lean_beacon_justified_balances_lookups_total", + "Beacon fork-choice justified-balances snapshot lookups, by whether the cached snapshot served them", + &["result"] + ) + .unwrap() + }, +); + +/// Count one justified-balances lookup: `hit` (the cached snapshot matched the +/// justified checkpoint) or `miss` (it was rebuilt from the checkpoint state). +pub fn inc_justified_balances_lookups(result: &str) { + LEAN_BEACON_JUSTIFIED_BALANCES_LOOKUPS_TOTAL + .with_label_values(&[result]) + .inc(); +} + +static LEAN_BEACON_JUSTIFIED_BALANCES_BUILD_SECONDS: LazyLock = LazyLock::new(|| { + register_histogram!( + "lean_beacon_justified_balances_build_seconds", + "Duration of one justified-balances snapshot build (one pass over the checkpoint state's registry)", + vec![0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0] + ) + .unwrap() +}); + +/// Time one justified-balances snapshot build, excluding the checkpoint state +/// lookup it starts from. +pub fn time_justified_balances_build() -> TimingGuard { + TimingGuard::new(&LEAN_BEACON_JUSTIFIED_BALANCES_BUILD_SECONDS) +} + +static LEAN_BEACON_EPOCH_PRECOMPUTE_LOOKUPS_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter_vec!( + "lean_beacon_epoch_precompute_lookups_total", + "Epoch-crossing block imports, by whether a precomputed epoch-boundary state was cached", + &["result"] + ) + .unwrap() +}); + +/// Count one epoch-crossing import's lookup of a precomputed state: `hit` or +/// `miss`. +pub fn inc_epoch_precompute_lookups(result: &str) { + LEAN_BEACON_EPOCH_PRECOMPUTE_LOOKUPS_TOTAL + .with_label_values(&[result]) + .inc(); +} + +static LEAN_BEACON_EPOCH_PRECOMPUTE_SECONDS: LazyLock = LazyLock::new(|| { + register_histogram!( + "lean_beacon_epoch_precompute_seconds", + "Duration of one epoch-transition precompute: advance, flush and hash", + vec![0.1, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0, 8.0] + ) + .unwrap() +}); + +/// Start timing one epoch precompute. Records duration when the guard is dropped. +pub fn time_epoch_precompute() -> TimingGuard { + TimingGuard::new(&LEAN_BEACON_EPOCH_PRECOMPUTE_SECONDS) +} + +static LEAN_BEACON_EPOCH_PRECOMPUTE_STARTED_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter_vec!( + "lean_beacon_epoch_precompute_started_total", + "Epoch precompute workers started, by what triggered them", + &["trigger"] + ) + .unwrap() +}); + +/// Count one precompute worker started: `head` (a last-slot block became head) +/// or `timer` (three quarters into a last slot with nothing computed). +pub fn inc_epoch_precompute_started(trigger: &str) { + LEAN_BEACON_EPOCH_PRECOMPUTE_STARTED_TOTAL + .with_label_values(&[trigger]) + .inc(); +} + static LEAN_STATE_TRANSITION_TIME_SECONDS: LazyLock = LazyLock::new(|| { register_histogram!( "lean_state_transition_time_seconds", diff --git a/crates/blockchain/state_transition/tests/beacon_spec/epoch_processing.rs b/crates/blockchain/state_transition/tests/beacon_spec/epoch_processing.rs index ebe40f15d..cc92102d9 100644 --- a/crates/blockchain/state_transition/tests/beacon_spec/epoch_processing.rs +++ b/crates/blockchain/state_transition/tests/beacon_spec/epoch_processing.rs @@ -185,15 +185,15 @@ fn apply( // module's own doc. `inactivity_updates` reads gloas too, below: its // participation and inactivity lists are progressive (EIP-7688), a // different Rust type from the bounded `SszList` altair's own copy - // used to need, but `BeaconState::inactivity_scores_mut` and - // `altair_validator_lists` both now reach either list kind through a - // shared slice type. `participation_flag_updates`, just below, is the + // used to need, but `BeaconState::inactivity_score_mut` and + // `altair_validator_lists` both now reach either list kind (the latter + // through `InactivityScoresRef`). `participation_flag_updates`, just below, is the // one exception: it *replaces* the whole list rather than writing an // element, which only the fork's own concrete container type can do, // so gloas keeps its own copy of that one step; see // `epoch::gloas`'s own module doc. "inactivity_updates" => match fork { - // `BeaconState::inactivity_scores_mut` reaches a gloas state's + // `BeaconState::inactivity_score_mut` reaches a gloas state's // scores too (element writes only, which both list kinds allow // through `DerefMut`; see that accessor's own doc), so this one // copy serves gloas as well. diff --git a/crates/blockchain/state_transition/tests/beacon_spec/fork_choice.rs b/crates/blockchain/state_transition/tests/beacon_spec/fork_choice.rs index fb901a2ef..4c2f4ea74 100644 --- a/crates/blockchain/state_transition/tests/beacon_spec/fork_choice.rs +++ b/crates/blockchain/state_transition/tests/beacon_spec/fork_choice.rs @@ -976,6 +976,108 @@ pub(super) fn apply_checks( if let Some(expected) = &checks.get_proposer_head { check_get_proposer_head(expected, store, config, committees)?; } + check_weights_against_the_spec(store, config)?; + check_node_weights_against_the_spec(store, config, committees) +} + +/// The gloas counterpart of [`check_weights_against_the_spec`], run at every +/// `checks` step of a case whose current slot is gloas. +/// +/// The head walk weighs payload-aware nodes with `compute_node_weights`, whose +/// vote loop reads the justified-balances snapshot; `gloas_get_weight` is the +/// specification's per-node definition (payload status, the previous-slot +/// rule, the gated proposer boost) and reads the justified checkpoint state +/// itself. Every node under the justified root is compared, not only the +/// leaves the `viable_for_head_roots_and_weights` check covers. +fn check_node_weights_against_the_spec( + store: &Store, + config: &Config, + committees: &CommitteeCache, +) -> Result<(), String> { + let current_slot = fork_choice::get_current_slot(store, config); + let fork = config.fork_at_epoch(current_slot / preset::SLOTS_PER_EPOCH); + if ForkRules::of(fork) != ForkRules::Gloas { + return Ok(()); + } + let index = store.block_index(); + let blocks = fork_choice::get_filtered_block_tree(store, &index, config) + .map_err(|err| format!("get_filtered_block_tree: {err:?}"))?; + let walked = + fork_choice::compute_node_weights(store, &index, config, committees, ForkRules::Gloas) + .map_err(|err| format!("compute_node_weights: {err:?}"))?; + let mut pending = vec![ForkChoiceNode { + root: store.beacon_justified_checkpoint().root, + payload_status: PayloadStatus::Pending, + }]; + while let Some(node) = pending.pop() { + let spec = match fork_choice::gloas_get_weight(store, node, config, committees) { + Ok(spec) => Some(spec), + // A vote for a block that invalidation removed from the index: the + // spec-literal weight raises on it and the walk drops it on + // purpose, so the two are not comparable there. + Err(err) if format!("{err:?}").contains("root in store.blocks") => None, + Err(err) => { + return Err(format!( + "gloas_get_weight(0x{}, {}): {err:?}", + hex::encode(node.root.0), + node.payload_status as u8 + )); + } + }; + if let Some(spec) = spec { + let block_slot = index + .get(&node.root) + .map(|&(slot, _)| slot) + .ok_or("a tree node is not in the block index")?; + let single_pass = walked.weight(node, block_slot); + if spec != single_pass { + return Err(format!( + "weight of 0x{} ({}): spec gloas_get_weight {spec}, compute_node_weights {single_pass}", + hex::encode(node.root.0), + node.payload_status as u8 + )); + } + } + pending.extend( + fork_choice::get_node_children(store, &blocks, node) + .map_err(|err| format!("get_node_children: {err:?}"))?, + ); + } + Ok(()) +} + +/// Oracle for the fork-choice weights, run at every `checks` step. +/// +/// No fixture checks weights directly, but `get_head` descends on +/// `compute_weights` (one pass over the votes, balances from the justified- +/// balances snapshot), while `get_weight` is the specification's per-root +/// definition and reads the justified checkpoint state itself. Comparing the +/// two for every block in the filtered tree puts the snapshot against the spec +/// path on every fixture, whatever the fixture asserts. +fn check_weights_against_the_spec(store: &Store, config: &Config) -> Result<(), String> { + let index = store.block_index(); + let tree = fork_choice::get_filtered_block_tree(store, &index, config) + .map_err(|err| format!("get_filtered_block_tree: {err:?}"))?; + let weights = fork_choice::compute_weights(store, &index, config) + .map_err(|err| format!("compute_weights: {err:?}"))?; + for root in tree.keys() { + let spec = match fork_choice::get_weight(store, &index, *root, config) { + Ok(spec) => spec, + // A vote for a block that invalidation removed from the index + // (`sync/optimistic`): the specification's `get_weight` raises on + // it, and `compute_weights` drops such a vote on purpose, so the + // two are not comparable for that case. + Err(err) if format!("{err:?}").contains("root in store.blocks") => continue, + Err(err) => return Err(format!("get_weight(0x{}): {err:?}", hex::encode(root.0))), + }; + let single_pass = weights.get(root).copied().unwrap_or_default(); + if spec != single_pass { + return Err(format!( + "weight of 0x{}: spec get_weight {spec}, compute_weights {single_pass}", + hex::encode(root.0) + )); + } + } Ok(()) } diff --git a/crates/common/ssz-tree/src/lib.rs b/crates/common/ssz-tree/src/lib.rs index bb3866152..fbac1f77b 100644 --- a/crates/common/ssz-tree/src/lib.rs +++ b/crates/common/ssz-tree/src/lib.rs @@ -91,6 +91,19 @@ pub use vector::Vector; use libssz::{SszDecode, SszEncode}; use libssz_merkle::HashTreeRoot; +/// The buffered-write surface of a [`List`] or [`Vector`], independent of its +/// element type and update map. +/// +/// Object safe, so a container can hold one `&dyn Buffered` per tree field of +/// different element types and flush or check them all from one field list. +pub trait Buffered { + /// Folds every buffered write into the tree. + fn apply_updates(&mut self); + + /// Whether any write is buffered and not yet folded into the tree. + fn has_pending_updates(&self) -> bool; +} + /// A 32-byte Merkle node. pub(crate) type Hash256 = libssz_merkle::Node; diff --git a/crates/common/ssz-tree/src/list.rs b/crates/common/ssz-tree/src/list.rs index 62efc6ab8..a36fbb57a 100644 --- a/crates/common/ssz-tree/src/list.rs +++ b/crates/common/ssz-tree/src/list.rs @@ -137,6 +137,16 @@ impl> List { } } +impl> crate::Buffered for List { + fn apply_updates(&mut self) { + List::apply_updates(self); + } + + fn has_pending_updates(&self) -> bool { + List::has_pending_updates(self) + } +} + impl> Default for List { fn default() -> Self { Self::empty() diff --git a/crates/common/ssz-tree/src/progressive_list.rs b/crates/common/ssz-tree/src/progressive_list.rs index 861b5b187..63faaf4a2 100644 --- a/crates/common/ssz-tree/src/progressive_list.rs +++ b/crates/common/ssz-tree/src/progressive_list.rs @@ -230,6 +230,16 @@ impl> ProgressiveIterCow<'_, T, U> { } } +impl> crate::Buffered for ProgressiveList { + fn apply_updates(&mut self) { + ProgressiveList::apply_updates(self); + } + + fn has_pending_updates(&self) -> bool { + ProgressiveList::has_pending_updates(self) + } +} + impl> Default for ProgressiveList { fn default() -> Self { Self::empty() diff --git a/crates/common/ssz-tree/src/vector.rs b/crates/common/ssz-tree/src/vector.rs index 45a1d0238..bea49b4a5 100644 --- a/crates/common/ssz-tree/src/vector.rs +++ b/crates/common/ssz-tree/src/vector.rs @@ -108,6 +108,16 @@ impl> Vector { } } +impl> crate::Buffered for Vector { + fn apply_updates(&mut self) { + Vector::apply_updates(self); + } + + fn has_pending_updates(&self) -> bool { + Vector::has_pending_updates(self) + } +} + impl> Default for Vector { /// A vector of `N` default-valued elements. fn default() -> Self { diff --git a/crates/common/ssz-tree/tests/model.rs b/crates/common/ssz-tree/tests/model.rs index a56db8597..cafc76a6e 100644 --- a/crates/common/ssz-tree/tests/model.rs +++ b/crates/common/ssz-tree/tests/model.rs @@ -1279,3 +1279,221 @@ proptest! { check_list_matches(&list, &items_model)?; } } + +// ── Shapes the beacon state's tree fields take ── + +/// A 32-byte transparent newtype whose `HashTreeRoot` is derived, so it +/// reports a composite (the shape `H256` had before it forwarded the answer). +#[derive(Debug, Clone, Copy, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)] +#[ssz(transparent)] +struct CompositeHash([u8; 32]); + +/// The same newtype with `is_basic_type` forwarded, as `H256` now does. +#[derive(Debug, Clone, Copy, PartialEq, Eq, SszEncode, SszDecode)] +#[ssz(transparent)] +struct BasicHash([u8; 32]); + +impl HashTreeRoot for BasicHash { + fn hash_tree_root(&self, hasher: &impl libssz_merkle::Sha256Hasher) -> libssz_merkle::Node { + HashTreeRoot::hash_tree_root(&self.0, hasher) + } + + fn is_basic_type() -> bool { + <[u8; 32] as HashTreeRoot>::is_basic_type() + } +} + +fn composite_hash() -> impl Strategy + Clone { + prop_oneof![ + Just(CompositeHash([0; 32])), + any::<[u8; 32]>().prop_map(CompositeHash) + ] +} + +fn basic_hash() -> impl Strategy + Clone { + prop_oneof![ + Just(BasicHash([0; 32])), + any::<[u8; 32]>().prop_map(BasicHash) + ] +} + +fn zero_prone_u8() -> impl Strategy + Clone { + prop_oneof![3 => Just(0u8), 1 => any::()] +} + +/// Like `check_rebase`, for vectors, which never change length. +fn rebase_vector( + base_values: Vec, + writes: Vec<(usize, T)>, + hash_base_first: bool, +) -> Result<(), TestCaseError> +where + T: Value + Debug, + U: UpdateMap, +{ + let base = Vector::::try_from(base_values.clone()).unwrap(); + if hash_base_first { + root(&base); + } + let mut model = base_values.clone(); + let mut orig = base.clone(); + for (index, value) in writes { + let index = index % N; + orig[index] = value.clone(); + model[index] = value; + } + orig.apply_updates(); + // A decoded copy shares nothing with `base`, so sharing comes from the rebase. + let bytes = orig.to_ssz(); + let mut orig = Vector::::from_ssz_bytes(&bytes).unwrap(); + + orig.rebase_on(&base); + + let reference = SszVector::::try_from(model.clone()).unwrap(); + prop_assert_eq!(orig.to_vec(), model.clone()); + prop_assert_eq!(root(&orig), root(&reference)); + let base_reference = SszVector::::try_from(base_values.clone()).unwrap(); + prop_assert_eq!(base.to_vec(), base_values.clone()); + prop_assert_eq!(root(&base), root(&base_reference)); + if model == base_values { + prop_assert!(orig.ptr_eq(&base)); + } + Ok(()) +} + +proptest! { + /// A leaf holds 4096 u8s: this spans several, with the last partial. + #[test] + fn u8_list_spanning_leaves( + initial in vec(any::(), 0..10_000), + ops in ops(any::()), + ) { + run_list::>(initial, ops)?; + } + + /// A leaf holds 512 u64s: 8192 slashings span 16. + #[test] + fn u64_vector_spanning_leaves( + initial in vec(any::(), 8192), + writes in vec((any::(), any::(), any::()), 0..40), + ) { + run_vector::>(initial, writes)?; + } + + /// A leaf holds 128 roots: 300 span three. + #[test] + fn composite_hash_vector_spanning_leaves( + initial in vec(composite_hash(), 300), + writes in vec((any::(), composite_hash(), any::()), 0..40), + ) { + run_vector::>(initial, writes)?; + } + + #[test] + fn basic_hash_vector_spanning_leaves( + initial in vec(basic_hash(), 300), + writes in vec((any::(), basic_hash(), any::()), 0..40), + ) { + run_vector::>(initial, writes)?; + } + + #[test] + fn basic_hash_list_spanning_leaves( + initial in vec(basic_hash(), 0..300), + ops in ops(basic_hash()), + ) { + run_list::>(initial, ops)?; + } + + #[test] + fn basic_and_composite_hash_collections_have_one_root( + values in vec(any::<[u8; 32]>(), 0..300), + ) { + let basic = List::::try_from( + values.iter().copied().map(BasicHash).collect::>(), + ).unwrap(); + let composite = List::::try_from( + values.iter().copied().map(CompositeHash).collect::>(), + ).unwrap(); + prop_assert_eq!(root(&basic), root(&composite)); + prop_assert_eq!(basic.to_ssz(), composite.to_ssz()); + } + + #[test] + fn u8_list_decode_parity(bytes in maybe_valid_bytes(vec(any::(), 0..=300))) { + list_decode_parity::(&bytes)?; + } + + #[test] + fn basic_hash_vector_decode_parity(bytes in maybe_valid_bytes(vec(basic_hash(), 0..=9))) { + vector_decode_parity::(&bytes)?; + } + + #[test] + fn u64_vector_decode_parity_multi_leaf(bytes in maybe_valid_bytes(vec(any::(), 0..=1100))) { + vector_decode_parity::(&bytes)?; + } + + // Mostly-zero lists: the trailing-zero trap in `rebase_on`. + #[test] + fn u8_rebase_grown( + base_values in vec(zero_prone_u8(), 0..9000), + ops in ops(zero_prone_u8()), + hash_base_first in any::(), + hash_orig_first in any::(), + ) { + rebase_grown::>(base_values, ops, hash_base_first, hash_orig_first)?; + } + + #[test] + fn u8_rebase_shrunk( + orig_values in vec(zero_prone_u8(), 0..9000), + extra in vec(zero_prone_u8(), 0..300), + hash_orig_first in any::(), + hash_base_first in any::(), + ) { + rebase_shrunk::>(orig_values, extra, hash_orig_first, hash_base_first)?; + } + + #[test] + fn u64_btree_rebase_grown_spanning_leaves( + base_values in vec(zero_prone_u64(), 0..1400), + ops in ops(zero_prone_u64()), + hash_base_first in any::(), + hash_orig_first in any::(), + ) { + rebase_grown::>(base_values, ops, hash_base_first, hash_orig_first)?; + } + + #[test] + fn u64_vector_rebase( + base_values in vec(zero_prone_u64(), 1024), + writes in vec((any::(), zero_prone_u64()), 0..8), + hash_base_first in any::(), + ) { + rebase_vector::>(base_values, writes, hash_base_first)?; + } + + #[test] + fn basic_hash_vector_rebase( + base_values in vec(basic_hash(), 300), + writes in vec((any::(), basic_hash()), 0..8), + hash_base_first in any::(), + ) { + rebase_vector::>(base_values, writes, hash_base_first)?; + } +} + +#[test] +fn buffered_forwards_to_lists_and_vectors() { + use ethlambda_ssz_tree::Buffered; + + let mut list = List::::try_from(vec![1, 2, 3]).unwrap(); + let mut vector = Vector::<[u8; 32], 4>::try_from(vec![[0u8; 32]; 4]).unwrap(); + list[0] = 9; + vector[1] = [7; 32]; + let mut fields: Vec<&mut dyn Buffered> = vec![&mut list, &mut vector]; + assert!(fields.iter().all(|f| f.has_pending_updates())); + fields.iter_mut().for_each(|f| f.apply_updates()); + assert!(fields.iter().all(|f| !f.has_pending_updates())); +} diff --git a/crates/common/types/src/beacon/committees.rs b/crates/common/types/src/beacon/committees.rs index 85875c869..12a35bf02 100644 --- a/crates/common/types/src/beacon/committees.rs +++ b/crates/common/types/src/beacon/committees.rs @@ -104,6 +104,13 @@ impl EpochCommittees { self.committees_per_slot } + /// How many validators are active in this epoch: the shuffle is a + /// permutation of exactly that set, so its length is the count without + /// another registry scan. + pub fn active_validator_count(&self) -> u64 { + self.shuffled.len() as u64 + } + /// The committee at `slot` with `index`. /// /// The same members `ethlambda-state-transition`'s `get_beacon_committee` diff --git a/crates/common/types/src/beacon/containers/mod.rs b/crates/common/types/src/beacon/containers/mod.rs index 9bcd8947c..a7859de2e 100644 --- a/crates/common/types/src/beacon/containers/mod.rs +++ b/crates/common/types/src/beacon/containers/mod.rs @@ -406,6 +406,64 @@ impl BeaconState { } } +/// Lists the tree-backed fields of one fork's state, once, and derives from +/// that list both the flush (`apply_pending_mutations`) and the pending check +/// (`has_pending_mutations`), so the two cannot drift apart when a field moves +/// onto the tree. +/// +/// The fields are handed out as `dyn Buffered`, since they differ in element +/// type and update map. +macro_rules! tree_fields { + ($fork:ty => $($field:ident),+ $(,)?) => { + impl $fork { + fn buffered(&self) -> [&dyn ethlambda_ssz_tree::Buffered; tree_fields!(@count $($field)+)] { + [$(&self.$field),+] + } + + fn buffered_mut( + &mut self, + ) -> [&mut dyn ethlambda_ssz_tree::Buffered; tree_fields!(@count $($field)+)] { + [$(&mut self.$field),+] + } + } + }; + (@count) => { 0usize }; + (@count $head:ident $($tail:ident)*) => { 1usize + tree_fields!(@count $($tail)*) }; +} + +tree_fields!( + phase0::BeaconState => validators, balances, block_roots, state_roots, historical_roots, eth1_data_votes, randao_mixes, slashings +); +tree_fields!( + altair::BeaconState => validators, balances, block_roots, state_roots, historical_roots, eth1_data_votes, randao_mixes, slashings, inactivity_scores +); +tree_fields!( + bellatrix::BeaconState => validators, balances, block_roots, state_roots, historical_roots, eth1_data_votes, randao_mixes, slashings, inactivity_scores +); +tree_fields!( + capella::BeaconState => validators, balances, block_roots, state_roots, historical_roots, eth1_data_votes, randao_mixes, slashings, inactivity_scores, + historical_summaries +); +tree_fields!( + deneb::BeaconState => validators, balances, block_roots, state_roots, historical_roots, eth1_data_votes, randao_mixes, slashings, inactivity_scores, + historical_summaries +); +tree_fields!( + electra::BeaconState => validators, balances, block_roots, state_roots, historical_roots, eth1_data_votes, randao_mixes, slashings, inactivity_scores, + historical_summaries +); +tree_fields!( + fulu::BeaconState => validators, balances, block_roots, state_roots, historical_roots, eth1_data_votes, randao_mixes, slashings, inactivity_scores, + historical_summaries +); +// Gloas keeps `validators` and `balances` in progressive trees, and its +// `inactivity_scores` in a flat progressive list that buffers nothing, so the +// scores are not listed. Every other field is the shared tree type. +tree_fields!( + gloas::BeaconState => validators, balances, block_roots, state_roots, historical_roots, eth1_data_votes, randao_mixes, slashings, + historical_summaries +); + /// Generates read and write accessors for state fields that every fork shares. /// /// The `copy` and `reference` lists are this crate's statement of which state @@ -555,6 +613,133 @@ enum RegistryMut<'a> { Progressive(&'a mut ProgressiveValidators, &'a mut ProgressiveBalances), } +/// The inactivity scores of a state, whichever list kind its fork uses. +/// +/// Every fork before gloas keeps them in a tree-backed list, which has no +/// slice view; gloas keeps a flat progressive list. A read needs only length, +/// element and in-order access, which this offers over both. +#[derive(Clone, Copy)] +pub enum InactivityScoresRef<'a> { + /// A tree-backed list (altair through fulu). + Tree(&'a InactivityScores), + /// A flat list (gloas). + Flat(&'a [u64]), +} + +impl<'a> From<&'a InactivityScores> for InactivityScoresRef<'a> { + fn from(scores: &'a InactivityScores) -> Self { + Self::Tree(scores) + } +} + +impl<'a> From<&'a gloas::InactivityScores> for InactivityScoresRef<'a> { + fn from(scores: &'a gloas::InactivityScores) -> Self { + Self::Flat(&scores[..]) + } +} + +impl<'a> InactivityScoresRef<'a> { + /// The number of scores. + pub fn len(&self) -> usize { + match self { + Self::Tree(scores) => scores.len(), + Self::Flat(scores) => scores.len(), + } + } + + /// Whether there are no scores. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// The score at `index`, or `None` past the end. A tree descent for a + /// tree-backed list: walk [`Self::iter`] when visiting many. + pub fn get(&self, index: usize) -> Option<&'a u64> { + match self { + Self::Tree(scores) => scores.get(index), + Self::Flat(scores) => scores.get(index), + } + } + + /// Every score in order. + pub fn iter(&self) -> impl ExactSizeIterator + use<'a> { + match self { + Self::Tree(scores) => ScoresIter::Tree(scores.iter()), + Self::Flat(scores) => ScoresIter::Flat(scores.iter()), + } + } + + /// Whether a write is buffered and not yet folded into the tree. Always + /// `false` for a flat list, which buffers nothing. + pub fn has_pending_updates(&self) -> bool { + match self { + Self::Tree(scores) => scores.has_pending_updates(), + Self::Flat(_) => false, + } + } + + /// Whether both are tree-backed lists sharing one allocation. Never true + /// for a flat list, which shares nothing. + pub fn ptr_eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Tree(a), Self::Tree(b)) => a.ptr_eq(b), + _ => false, + } + } + + /// The scores, copied out. + pub fn to_vec(&self) -> Vec { + self.iter().copied().collect() + } +} + +impl PartialEq for InactivityScoresRef<'_> { + fn eq(&self, other: &Self) -> bool { + self.iter().eq(other.iter()) + } +} + +impl std::fmt::Debug for InactivityScoresRef<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_list().entries(self.iter()).finish() + } +} + +impl std::ops::Index for InactivityScoresRef<'_> { + type Output = u64; + + fn index(&self, index: usize) -> &u64 { + self.get(index) + .unwrap_or_else(|| panic!("index {index} out of bounds for {} scores", self.len())) + } +} + +/// The iterator behind [`InactivityScoresRef::iter`]. +enum ScoresIter<'a> { + Tree(ethlambda_ssz_tree::Iter<'a, u64, std::collections::BTreeMap>), + Flat(std::slice::Iter<'a, u64>), +} + +impl<'a> Iterator for ScoresIter<'a> { + type Item = &'a u64; + + fn next(&mut self) -> Option<&'a u64> { + match self { + Self::Tree(it) => it.next(), + Self::Flat(it) => it.next(), + } + } + + fn size_hint(&self) -> (usize, Option) { + match self { + Self::Tree(it) => it.size_hint(), + Self::Flat(it) => it.size_hint(), + } + } +} + +impl ExactSizeIterator for ScoresIter<'_> {} + /// An iterator over either registry list kind, so [`BeaconState::iter_validators`] /// and [`BeaconState::iter_balances`] can promise one return type across every /// fork despite [`Registry`]'s two underlying list types having different @@ -753,9 +938,9 @@ impl BeaconState { } } - /// Folds every buffered write into the tree-backed fields (`validators`, - /// `balances`), so the next `hash_tree_root` rehashes only the touched - /// paths and keeps the hashes it computes. + /// Folds every buffered write into the tree-backed fields (see + /// `tree_fields!` for which ones), so the next `hash_tree_root` rehashes + /// only the touched paths and keeps the hashes it computes. /// /// Hashing with writes still pending gives the right root but caches /// nothing for those paths, so the state transition calls this before @@ -765,13 +950,13 @@ impl BeaconState { if matches!(self, BeaconState::Lean(_)) { return; } - dispatch_state!(self, "apply_pending_mutations", |state| { - state.validators.apply_updates(); - state.balances.apply_updates(); - }) + dispatch_state!(self, "apply_pending_mutations", |state| state + .buffered_mut() + .into_iter() + .for_each(|field| field.apply_updates())) } - /// Whether `validators` or `balances` has a write [`apply_pending_mutations`] + /// Whether any tree-backed field has a write [`apply_pending_mutations`] /// has not folded into its tree yet. /// /// Always `false` on a lean state, which has no tree-backed fields. Meant @@ -781,11 +966,13 @@ impl BeaconState { /// /// [`apply_pending_mutations`]: BeaconState::apply_pending_mutations pub fn has_pending_mutations(&self) -> bool { - match self.registry() { - Some(Registry::Bounded(v, b)) => v.has_pending_updates() || b.has_pending_updates(), - Some(Registry::Progressive(v, b)) => v.has_pending_updates() || b.has_pending_updates(), - None => false, + if matches!(self, BeaconState::Lean(_)) { + return false; } + dispatch_state!(self, "has_pending_mutations", |state| state + .buffered() + .into_iter() + .any(|field| field.has_pending_updates())) } /// Makes this state's tree-backed fields share every unchanged subtree @@ -795,10 +982,12 @@ impl BeaconState { /// /// Works across every fork that shares a registry list kind with `base`, /// since `List::rebase_on` and `ProgressiveList::rebase_on` each take a - /// `&Self`, not some common trait object. A no-op if either state is - /// lean, or if the two disagree on list kind (a gloas state rebased onto - /// a pre-gloas one, or vice versa): that pairing only happens across the - /// fork boundary itself, where there is no shared tree to reuse anyway. + /// `&Self`, not some common trait object; the other tree-backed fields + /// have one type in every fork. A no-op if either state is lean, and the + /// registry is left alone if the two disagree on list kind (a gloas state + /// rebased onto a pre-gloas one, or vice versa): that pairing only + /// happens across the fork boundary itself, where there is no shared tree + /// to reuse anyway. pub fn rebase_on(&mut self, base: &BeaconState) { let Some(base_registry) = base.registry() else { return; @@ -819,6 +1008,72 @@ impl BeaconState { | (Some(RegistryMut::Bounded(..)), Registry::Progressive(..)) | (Some(RegistryMut::Progressive(..)), Registry::Bounded(..)) => {} } + self.block_roots_mut().rebase_on(base.block_roots()); + self.state_roots_mut().rebase_on(base.state_roots()); + self.historical_roots_mut() + .rebase_on(base.historical_roots()); + self.eth1_data_votes_mut().rebase_on(base.eth1_data_votes()); + self.randao_mixes_mut().rebase_on(base.randao_mixes()); + self.slashings_mut().rebase_on(base.slashings()); + self.rebase_inactivity_scores_on(base); + self.rebase_historical_summaries_on(base); + } + + /// Whether `validators` or `balances` has a write + /// [`Self::apply_pending_mutations`] has not folded into its tree yet. + /// `false` on a lean state. + /// + /// The registry alone, unlike [`Self::has_pending_mutations`], which also + /// counts the other tree-backed fields: the per-slot roots writes stay + /// buffered by design until the next flush, so a caller asking whether + /// the registry is flushed cannot use that one. + pub fn registry_has_pending_updates(&self) -> bool { + match self.registry() { + Some(Registry::Bounded(v, b)) => v.has_pending_updates() || b.has_pending_updates(), + Some(Registry::Progressive(v, b)) => v.has_pending_updates() || b.has_pending_updates(), + None => false, + } + } + + /// Shares `inactivity_scores` with `base`'s where both are tree-backed. + /// Phase0 has no scores, and gloas keeps a flat list with nothing to + /// share, so those pairings (and any mix of the two kinds) do nothing. + fn rebase_inactivity_scores_on(&mut self, base: &BeaconState) { + if let (Ok((_, _, scores)), Ok((_, _, InactivityScoresRef::Tree(base_scores)))) = ( + self.altair_validator_lists_mut(), + base.altair_validator_lists(), + ) { + scores.rebase_on(base_scores); + } + } + + /// Shares `historical_summaries` with `base`'s where both carry it, which + /// is every fork from capella on. Earlier forks have no summaries, so any + /// pairing with one of those does nothing. + fn rebase_historical_summaries_on(&mut self, base: &BeaconState) { + let base_summaries = match base { + BeaconState::Capella(state) => &state.historical_summaries, + BeaconState::Deneb(state) => &state.historical_summaries, + BeaconState::Electra(state) => &state.historical_summaries, + BeaconState::Fulu(state) => &state.historical_summaries, + BeaconState::Gloas(state) => &state.historical_summaries, + BeaconState::Phase0(_) + | BeaconState::Altair(_) + | BeaconState::Bellatrix(_) + | BeaconState::Lean(_) => return, + }; + let summaries = match self { + BeaconState::Capella(state) => &mut state.historical_summaries, + BeaconState::Deneb(state) => &mut state.historical_summaries, + BeaconState::Electra(state) => &mut state.historical_summaries, + BeaconState::Fulu(state) => &mut state.historical_summaries, + BeaconState::Gloas(state) => &mut state.historical_summaries, + BeaconState::Phase0(_) + | BeaconState::Altair(_) + | BeaconState::Bellatrix(_) + | BeaconState::Lean(_) => return, + }; + summaries.rebase_on(base_summaries); } /// Runs `f` over every balance in order through the list's write cursor @@ -920,7 +1175,9 @@ impl BeaconState { } /// The three per-validator lists that exist from altair on, by reference and - /// all at once, as plain slices. + /// all at once: the two participation lists as plain slices, the inactivity + /// scores as an [`InactivityScoresRef`] since a tree-backed list has no + /// slice to hand out. /// /// These cannot join `shared_state_accessors`' lists, since phase0 has /// none of them, and a per-fork projection to a concrete state struct (the @@ -940,7 +1197,7 @@ impl BeaconState { /// kinds: the write side still needs the concrete container type, to grow /// or replace the whole list, which is why [`Self::altair_validator_lists_mut`] /// stays bounded-only. On gloas, element writes go through - /// [`Self::inactivity_scores_mut`], and growing or replacing a list goes + /// [`Self::inactivity_score_mut`], and growing or replacing a list goes /// through a per-fork projection. /// /// Handed back together rather than one accessor per field for the same @@ -950,14 +1207,18 @@ impl BeaconState { /// the rest away with `_`. pub fn altair_validator_lists( &self, - ) -> Result<(&[ParticipationFlags], &[ParticipationFlags], &[u64])> { + ) -> Result<( + &[ParticipationFlags], + &[ParticipationFlags], + InactivityScoresRef<'_>, + )> { dispatch_state_from!( self, "BeaconState::altair_validator_lists", |state| ( &state.previous_epoch_participation[..], &state.current_epoch_participation[..], - &state.inactivity_scores[..], + InactivityScoresRef::from(&state.inactivity_scores), ), carried_by: [Altair, Bellatrix, Capella, Deneb, Electra, Fulu, Gloas], absent_from: [Phase0], @@ -992,7 +1253,7 @@ impl BeaconState { /// registry through a per-fork projection /// (`PendingQueueFields::push_empty_participation_and_inactivity`), writes /// one score in place through - /// [`Self::inactivity_scores_mut`], or replaces a whole list outright + /// [`Self::inactivity_score_mut`], or replaces a whole list outright /// through its own state's own field, the way /// `stf::epoch::gloas::process_participation_flag_updates` does. pub fn altair_validator_lists_mut( @@ -1015,13 +1276,16 @@ impl BeaconState { ) } - /// `inactivity_scores`, mutably, as a plain slice: element writes only, no + /// One of `inactivity_scores`, mutably: an element write only, no /// whole-list replace or length change, which is what lets this include /// gloas where [`Self::altair_validator_lists_mut`] cannot (see that - /// accessor's own doc). A `&mut [u64]` cannot grow or shrink, so handing - /// one out cannot break either list kind's own length invariant, the same - /// reasoning [`libssz_types::ProgressiveList`]'s own `DerefMut` doc gives - /// for allowing element mutation but not resizing through a slice. + /// accessor's own doc). A `&mut u64` cannot grow or shrink either list + /// kind, so handing one out cannot break its length invariant. + /// + /// On a tree-backed list (every fork before gloas) the element is copied + /// into the pending-write map on first touch, whether or not the caller + /// then changes it: write only when the value differs, so an unchanged + /// score leaves the list shared with its parent. /// /// `stf::epoch::altair::process_inactivity_updates` (shared by every fork /// from altair on, gloas included) is the one caller: it only ever writes @@ -1029,11 +1293,17 @@ impl BeaconState { /// `add_validator_to_registry` and `process_participation_flag_updates` /// do instead (see [`Self::altair_validator_lists_mut`]'s own doc for /// where each of those goes). - pub fn inactivity_scores_mut(&mut self) -> Result<&mut [u64]> { + pub fn inactivity_score_mut(&mut self, index: usize) -> Result<&mut u64> { dispatch_state_from!( self, - "BeaconState::inactivity_scores_mut", - |state| &mut state.inactivity_scores[..], + "BeaconState::inactivity_score_mut", + |state| { + let len = state.inactivity_scores.len(); + state + .inactivity_scores + .get_mut(index) + .ok_or(Error::IndexOutOfBounds { index, len })? + }, carried_by: [Altair, Bellatrix, Capella, Deneb, Electra, Fulu, Gloas], absent_from: [Phase0], ) @@ -1041,7 +1311,7 @@ impl BeaconState { /// `previous_epoch_participation` or `current_epoch_participation`, /// mutably and as a plain slice, whichever `current` selects: element - /// writes only, the same contract [`Self::inactivity_scores_mut`]'s own + /// writes only, the same contract [`Self::inactivity_score_mut`]'s own /// doc gives for why that is enough to include gloas where /// [`Self::altair_validator_lists_mut`] cannot. /// diff --git a/crates/common/types/src/beacon/containers/shared.rs b/crates/common/types/src/beacon/containers/shared.rs index b2df90691..0b024f9f1 100644 --- a/crates/common/types/src/beacon/containers/shared.rs +++ b/crates/common/types/src/beacon/containers/shared.rs @@ -9,7 +9,7 @@ use std::collections::BTreeMap; -use ethlambda_ssz_tree::{List, ProgressiveList as TreeProgressiveList}; +use ethlambda_ssz_tree::{List, ProgressiveList as TreeProgressiveList, Vector}; use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; use libssz_types::{SszBitvector, SszList, SszVector}; @@ -29,19 +29,29 @@ use crate::beacon::primitives::{ /// The rolling window of recent block roots the state keeps, indexed by slot /// modulo its length so it acts as a ring buffer. -pub type BlockRoots = SszVector; +/// +/// Tree-backed, like every large field: a slot writes one entry, so a derived +/// state shares nearly all of the buffer with its parent and rehashes only the +/// leaf it touched. Writes are buffered in a `BTreeMap`, since they are a +/// handful per flush, until `BeaconState::apply_pending_mutations`. +pub type BlockRoots = Vector>; /// The rolling window of recent state roots, indexed the same way as /// [`BlockRoots`]. -pub type StateRoots = SszVector; +pub type StateRoots = Vector>; /// Accumulated roots of [`HistoricalBatch`], one appended per historical batch, /// which is how the chain keeps a commitment to history older than the rolling /// windows without keeping the roots themselves. -pub type HistoricalRoots = SszList; +/// +/// Tree-backed like [`BlockRoots`]. +pub type HistoricalRoots = List>; /// Eth1 data votes accumulated over one voting period, tallied and then reset. -pub type Eth1DataVotes = SszList; +/// +/// Tree-backed like [`BlockRoots`]. +pub type Eth1DataVotes = + List>; /// The validator registry. Append-only: a validator is never removed, only /// exited, since indices are referenced by attestations and must stay stable. @@ -66,13 +76,18 @@ pub type Balances = List; /// Past randao mixes, indexed by epoch modulo the vector length, so the state /// retains a bounded history of the beacon chain's randomness. -pub type RandaoMixes = SszVector; +/// +/// Tree-backed like [`BlockRoots`]. +pub type RandaoMixes = + Vector>; /// Slashed balance totals per epoch, indexed by epoch modulo the vector length. /// Epoch processing reads the whole vector to size the proportional slashing /// penalty, which is what makes correlated slashings cost more than isolated /// ones. -pub type Slashings = SszVector; +/// +/// Tree-backed like [`BlockRoots`]. +pub type Slashings = Vector>; /// One bit per recent epoch recording whether it was justified, which is the /// state that lets finalization look back over several epochs at once. @@ -91,11 +106,19 @@ pub type EpochParticipation = SszList; +/// +/// Tree-backed: outside a leak almost every score is zero and epoch processing +/// skips writes that change nothing, so a state derived from another shares +/// nearly the whole list with it and rehashes only the touched leaves. Writes +/// are buffered in a `BTreeMap`, since they are sparse in that case. +pub type InactivityScores = List>; /// Accumulated [`HistoricalSummary`] entries, which replace [`HistoricalRoots`] /// as the commitment to history from capella onward. -pub type HistoricalSummaries = SszList; +/// +/// Tree-backed like [`BlockRoots`]. +pub type HistoricalSummaries = + List>; /// The gloas validator registry (EIP-7688): unbounded and progressively /// merkleized, tree-backed like [`Validators`] for the same reason (a diff --git a/crates/common/types/src/beacon/fork_choice.rs b/crates/common/types/src/beacon/fork_choice.rs index c39486a2a..d5318e2e1 100644 --- a/crates/common/types/src/beacon/fork_choice.rs +++ b/crates/common/types/src/beacon/fork_choice.rs @@ -20,7 +20,10 @@ use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; -use crate::beacon::primitives::{Epoch, ExecutionBlockHash, Root, Slot, Uint256}; +use crate::beacon::containers::Checkpoint; +use crate::beacon::primitives::{ + Epoch, ExecutionBlockHash, Gwei, Root, Slot, Uint256, ValidatorIndex, +}; /// One validator's most recent attestation: the epoch it targeted, and the /// block it attested to (the LMD GHOST vote). @@ -206,12 +209,118 @@ pub struct PayloadStatusV1 { pub validation_error: Option, } +/// The justified checkpoint state's balances, flattened for the fork-choice +/// vote loop: `store.checkpoint_states[checkpoint]` as `get_weight` and +/// `get_proposer_score` read it. +/// +/// A tree descent per vote (`state.validator(i)`) is an order of magnitude +/// dearer than an array read, and the loop runs once per `get_head` over every +/// voter. This is built once per justified checkpoint and keyed by it, so a +/// reader compares [`Self::checkpoint`] with the store's current one and +/// rebuilds on a mismatch, with no hook on the code that moves the checkpoint. +/// +/// Held in the store, hence here rather than in `ethlambda-state-transition`; +/// the builder lives there, next to the state accessors. +#[derive(Debug, PartialEq, Eq)] +pub struct JustifiedBalances { + checkpoint: Checkpoint, + /// Zero unless the validator is active at `checkpoint.epoch` and unslashed, + /// the two conditions under which a vote weighs anything. + balances: Box<[Gwei]>, + /// The sum of `balances`, floored at one increment: the total the + /// committee fraction behind the proposer boost and the reorg thresholds + /// divides. Not `get_total_active_balance`, which counts slashed + /// validators that are still active. + total_balance: Gwei, + /// Every validator's raw effective balance in `EFFECTIVE_BALANCE_INCREMENT` + /// units, whether or not it is active or slashed. Exact, since an + /// effective balance is always a whole number of increments and the + /// largest (`MAX_EFFECTIVE_BALANCE_ELECTRA`) is far below `u16::MAX` + /// increments; two bytes a validator instead of eight. + /// + /// `balances` zeroes a slashed validator, so it cannot answer the one + /// question that needs the raw figure: `is_head_weak` adds back the + /// effective balance of every equivocating validator in the head slot's + /// committees, and an equivocator is often slashed. + effective_increments: Box<[u16]>, +} + +impl JustifiedBalances { + /// Wraps already-computed values; see the field docs for what they mean. + pub fn new( + checkpoint: Checkpoint, + balances: Box<[Gwei]>, + total_balance: Gwei, + effective_increments: Box<[u16]>, + ) -> Self { + Self { + checkpoint, + balances, + total_balance, + effective_increments, + } + } + + /// `index`'s raw effective balance at the checkpoint, active and unslashed + /// or not; zero past the end, since that validator did not exist then. + pub fn effective_balance(&self, index: ValidatorIndex) -> Gwei { + usize::try_from(index) + .ok() + .and_then(|index| self.effective_increments.get(index)) + .map_or(0, |&increments| { + Gwei::from(increments) * crate::beacon::preset::EFFECTIVE_BALANCE_INCREMENT + }) + } + + /// The checkpoint these balances were derived from. + pub fn checkpoint(&self) -> Checkpoint { + self.checkpoint + } + + /// The weight of `index`'s vote: zero when inactive or slashed, and also + /// past the end, since that validator did not exist at the checkpoint. + pub fn get(&self, index: ValidatorIndex) -> Gwei { + usize::try_from(index) + .ok() + .and_then(|index| self.balances.get(index)) + .copied() + .unwrap_or_default() + } + + /// The effective balance of the checkpoint state's active, unslashed + /// validators, as the specification's `calculate_committee_fraction` + /// totals it. + pub fn total_balance(&self) -> Gwei { + self.total_balance + } +} + #[cfg(test)] mod tests { use libssz::{SszDecode as _, SszEncode as _}; use super::*; + #[test] + fn justified_balances_read_zero_past_the_registry() { + let balances = JustifiedBalances::new( + Checkpoint::default(), + vec![7, 0, 9].into(), + 16, + vec![1, 2, 3].into(), + ); + assert_eq!(balances.get(0), 7); + assert_eq!(balances.get(1), 0); + assert_eq!(balances.get(2), 9); + assert_eq!(balances.get(3), 0); + assert_eq!(balances.get(u64::MAX), 0); + assert_eq!(balances.total_balance(), 16); + let increment = crate::beacon::preset::EFFECTIVE_BALANCE_INCREMENT; + assert_eq!(balances.effective_balance(1), 2 * increment); + assert_eq!(balances.effective_balance(3), 0); + assert_eq!(balances.effective_balance(u64::MAX), 0); + } + #[test] fn a_pow_block_round_trips_through_ssz() { // The store persists these, so the derive has to survive the move out diff --git a/crates/common/types/src/primitives.rs b/crates/common/types/src/primitives.rs index 0e8cf99fe..4e0b3e9f5 100644 --- a/crates/common/types/src/primitives.rs +++ b/crates/common/types/src/primitives.rs @@ -31,11 +31,24 @@ pub type ByteList = libssz_types::SszList; Hash, libssz_derive::SszEncode, libssz_derive::SszDecode, - libssz_derive::HashTreeRoot, )] #[ssz(transparent)] pub struct H256(pub [u8; 32]); +/// Written out rather than derived because the `transparent` derive does not +/// forward `is_basic_type`. Without it a collection of `H256` is treated as +/// composite: the same root, but a tree-backed list would cache a second copy +/// of every element's root (see `ethlambda_ssz_tree`). +impl libssz_merkle::HashTreeRoot for H256 { + fn hash_tree_root(&self, hasher: &impl libssz_merkle::Sha256Hasher) -> libssz_merkle::Node { + libssz_merkle::HashTreeRoot::hash_tree_root(&self.0, hasher) + } + + fn is_basic_type() -> bool { + <[u8; 32] as libssz_merkle::HashTreeRoot>::is_basic_type() + } +} + impl serde::Serialize for H256 { fn serialize(&self, serializer: S) -> Result { serializer.serialize_str(&format!("{self}")) @@ -183,4 +196,99 @@ mod tests { fn h256_from_slice_too_long() { H256::from_slice(&[0u8; 33]); } + + /// Reporting `H256` as basic must not change any root: a collection of it + /// merkleizes like one of the plain arrays it wraps. + mod basic_type { + use super::*; + use libssz_types::{SszList, SszVector}; + + #[derive(libssz_derive::HashTreeRoot)] + struct WithH256 { + list: SszList, + vector: SszVector, + tail: u64, + } + + #[derive(libssz_derive::HashTreeRoot)] + struct WithArrays { + list: SszList<[u8; 32], 64>, + vector: SszVector<[u8; 32], 8>, + tail: u64, + } + + fn items(n: usize) -> Vec<[u8; 32]> { + (0..n).map(|i| [i as u8 ^ 0x5a; 32]).collect() + } + + #[test] + fn h256_reports_basic_like_its_array() { + assert!(::is_basic_type()); + } + + #[test] + fn list_and_vector_of_h256_hash_like_arrays() { + for n in [0, 1, 2, 3, 33, 64] { + let arrays = items(n); + let hashes: Vec = arrays.iter().copied().map(H256).collect(); + let a = SszList::<[u8; 32], 64>::try_from(arrays).unwrap(); + let h = SszList::::try_from(hashes).unwrap(); + assert_eq!( + HashTreeRoot::hash_tree_root(&a), + HashTreeRoot::hash_tree_root(&h), + "list of {n}" + ); + } + let arrays = items(8); + let hashes: Vec = arrays.iter().copied().map(H256).collect(); + let a = SszVector::<[u8; 32], 8>::try_from(arrays).unwrap(); + let h = SszVector::::try_from(hashes).unwrap(); + assert_eq!( + HashTreeRoot::hash_tree_root(&a), + HashTreeRoot::hash_tree_root(&h) + ); + } + + #[test] + fn a_container_holding_h256_collections_keeps_its_root() { + let arrays = items(8); + let hashes: Vec = arrays.iter().copied().map(H256).collect(); + let with_arrays = WithArrays { + list: arrays.clone().try_into().unwrap(), + vector: arrays.try_into().unwrap(), + tail: 7, + }; + let with_h256 = WithH256 { + list: hashes.clone().try_into().unwrap(), + vector: hashes.try_into().unwrap(), + tail: 7, + }; + assert_eq!( + HashTreeRoot::hash_tree_root(&with_arrays), + HashTreeRoot::hash_tree_root(&with_h256) + ); + } + + /// The lean state's `H256` lists go through the same path. + #[test] + fn lean_state_root_is_unchanged_by_the_basic_report() { + use crate::state::State; + let mut state = State::from_genesis(0, Vec::new()); + let hashes: Vec = items(5).into_iter().map(H256).collect(); + state.historical_block_hashes = hashes.try_into().unwrap(); + let mirror = SszList::<[u8; 32], 262_144>::try_from(items(5)).unwrap(); + let expected = + libssz_merkle::HashTreeRoot::hash_tree_root(&mirror, &libssz_merkle::Sha2Hasher); + let got = libssz_merkle::HashTreeRoot::hash_tree_root( + &state.historical_block_hashes, + &libssz_merkle::Sha2Hasher, + ); + assert_eq!(got, expected); + // The container root is computed and stable across calls. + assert_eq!( + HashTreeRoot::hash_tree_root(&state), + HashTreeRoot::hash_tree_root(&state.clone()) + ); + } + } } diff --git a/crates/common/types/tests/beacon_json.rs b/crates/common/types/tests/beacon_json.rs index 7809cf5ae..5f50bbd60 100644 --- a/crates/common/types/tests/beacon_json.rs +++ b/crates/common/types/tests/beacon_json.rs @@ -105,6 +105,16 @@ mod fixtures { SszVector::try_from(vec![value; N]).expect("exactly N elements by construction") } + /// [`vector`] for the state's tree-backed ring buffers. + pub fn tree_vector(value: T) -> ethlambda_ssz_tree::Vector + where + T: ethlambda_ssz_tree::Value, + U: ethlambda_ssz_tree::UpdateMap, + { + ethlambda_ssz_tree::Vector::try_from(vec![value; N]) + .expect("exactly N elements by construction") + } + pub fn checkpoint(seed: u8) -> shared::Checkpoint { shared::Checkpoint { epoch: u64::from(seed) + 1, @@ -349,18 +359,19 @@ fn phase0_state() -> phase0::BeaconState { epoch: 1, }, latest_block_header: fixtures::beacon_block_header(7), - block_roots: fixtures::vector(H256::repeat_byte(0x77)), - state_roots: fixtures::vector(H256::repeat_byte(0x88)), - historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + block_roots: fixtures::tree_vector(H256::repeat_byte(0x77)), + state_roots: fixtures::tree_vector(H256::repeat_byte(0x88)), + historical_roots: ethlambda_ssz_tree::List::try_from(vec![H256::repeat_byte(0x99)]) .expect("within capacity"), eth1_data: fixtures::eth1_data(8), - eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_data_votes: ethlambda_ssz_tree::List::try_from(vec![fixtures::eth1_data(9)]) + .expect("within capacity"), eth1_deposit_index: 3, validators: shared::Validators::try_from(vec![fixtures::validator(10)]) .expect("within capacity"), balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), - randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), - slashings: fixtures::vector(1_000_000_000u64), + randao_mixes: fixtures::tree_vector(H256::repeat_byte(0xaa)), + slashings: fixtures::tree_vector(1_000_000_000u64), previous_epoch_attestations: SszList::try_from(vec![pending_attestation(11)]) .expect("within capacity"), current_epoch_attestations: SszList::try_from(vec![pending_attestation(12)]) @@ -468,18 +479,19 @@ fn altair_state() -> altair::BeaconState { epoch: 1, }, latest_block_header: fixtures::beacon_block_header(7), - block_roots: fixtures::vector(H256::repeat_byte(0x77)), - state_roots: fixtures::vector(H256::repeat_byte(0x88)), - historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + block_roots: fixtures::tree_vector(H256::repeat_byte(0x77)), + state_roots: fixtures::tree_vector(H256::repeat_byte(0x88)), + historical_roots: ethlambda_ssz_tree::List::try_from(vec![H256::repeat_byte(0x99)]) .expect("within capacity"), eth1_data: fixtures::eth1_data(8), - eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_data_votes: ethlambda_ssz_tree::List::try_from(vec![fixtures::eth1_data(9)]) + .expect("within capacity"), eth1_deposit_index: 3, validators: shared::Validators::try_from(vec![fixtures::validator(10)]) .expect("within capacity"), balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), - randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), - slashings: fixtures::vector(1_000_000_000u64), + randao_mixes: fixtures::tree_vector(H256::repeat_byte(0xaa)), + slashings: fixtures::tree_vector(1_000_000_000u64), previous_epoch_participation: SszList::try_from(vec![7u8]).expect("within capacity"), current_epoch_participation: SszList::try_from(vec![9u8]).expect("within capacity"), justification_bits: { @@ -490,7 +502,7 @@ fn altair_state() -> altair::BeaconState { previous_justified_checkpoint: fixtures::checkpoint(13), current_justified_checkpoint: fixtures::checkpoint(14), finalized_checkpoint: fixtures::checkpoint(15), - inactivity_scores: SszList::try_from(vec![4u64]).expect("within capacity"), + inactivity_scores: vec![4u64].try_into().expect("within capacity"), current_sync_committee: sync_committee(16), next_sync_committee: sync_committee(17), } @@ -614,18 +626,19 @@ fn bellatrix_state() -> bellatrix::BeaconState { epoch: 1, }, latest_block_header: fixtures::beacon_block_header(7), - block_roots: fixtures::vector(H256::repeat_byte(0x77)), - state_roots: fixtures::vector(H256::repeat_byte(0x88)), - historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + block_roots: fixtures::tree_vector(H256::repeat_byte(0x77)), + state_roots: fixtures::tree_vector(H256::repeat_byte(0x88)), + historical_roots: ethlambda_ssz_tree::List::try_from(vec![H256::repeat_byte(0x99)]) .expect("within capacity"), eth1_data: fixtures::eth1_data(8), - eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_data_votes: ethlambda_ssz_tree::List::try_from(vec![fixtures::eth1_data(9)]) + .expect("within capacity"), eth1_deposit_index: 3, validators: shared::Validators::try_from(vec![fixtures::validator(10)]) .expect("within capacity"), balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), - randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), - slashings: fixtures::vector(1_000_000_000u64), + randao_mixes: fixtures::tree_vector(H256::repeat_byte(0xaa)), + slashings: fixtures::tree_vector(1_000_000_000u64), previous_epoch_participation: SszList::try_from(vec![7u8]).expect("within capacity"), current_epoch_participation: SszList::try_from(vec![9u8]).expect("within capacity"), justification_bits: { @@ -636,7 +649,7 @@ fn bellatrix_state() -> bellatrix::BeaconState { previous_justified_checkpoint: fixtures::checkpoint(13), current_justified_checkpoint: fixtures::checkpoint(14), finalized_checkpoint: fixtures::checkpoint(15), - inactivity_scores: SszList::try_from(vec![4u64]).expect("within capacity"), + inactivity_scores: vec![4u64].try_into().expect("within capacity"), current_sync_committee: sync_committee(16), next_sync_committee: sync_committee(17), latest_execution_payload_header: execution_payload_header(30), @@ -770,18 +783,19 @@ fn capella_state() -> capella::BeaconState { epoch: 1, }, latest_block_header: fixtures::beacon_block_header(7), - block_roots: fixtures::vector(H256::repeat_byte(0x77)), - state_roots: fixtures::vector(H256::repeat_byte(0x88)), - historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + block_roots: fixtures::tree_vector(H256::repeat_byte(0x77)), + state_roots: fixtures::tree_vector(H256::repeat_byte(0x88)), + historical_roots: ethlambda_ssz_tree::List::try_from(vec![H256::repeat_byte(0x99)]) .expect("within capacity"), eth1_data: fixtures::eth1_data(8), - eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_data_votes: ethlambda_ssz_tree::List::try_from(vec![fixtures::eth1_data(9)]) + .expect("within capacity"), eth1_deposit_index: 3, validators: shared::Validators::try_from(vec![fixtures::validator(10)]) .expect("within capacity"), balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), - randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), - slashings: fixtures::vector(1_000_000_000u64), + randao_mixes: fixtures::tree_vector(H256::repeat_byte(0xaa)), + slashings: fixtures::tree_vector(1_000_000_000u64), previous_epoch_participation: SszList::try_from(vec![7u8]).expect("within capacity"), current_epoch_participation: SszList::try_from(vec![9u8]).expect("within capacity"), justification_bits: { @@ -792,13 +806,13 @@ fn capella_state() -> capella::BeaconState { previous_justified_checkpoint: fixtures::checkpoint(13), current_justified_checkpoint: fixtures::checkpoint(14), finalized_checkpoint: fixtures::checkpoint(15), - inactivity_scores: SszList::try_from(vec![4u64]).expect("within capacity"), + inactivity_scores: vec![4u64].try_into().expect("within capacity"), current_sync_committee: sync_committee(16), next_sync_committee: sync_committee(17), latest_execution_payload_header: capella_execution_payload_header(30), next_withdrawal_index: 40, next_withdrawal_validator_index: 41, - historical_summaries: SszList::try_from(vec![shared::HistoricalSummary { + historical_summaries: ethlambda_ssz_tree::List::try_from(vec![shared::HistoricalSummary { block_summary_root: H256::repeat_byte(0xbb), state_summary_root: H256::repeat_byte(0xcc), }]) @@ -938,18 +952,19 @@ fn deneb_state() -> deneb::BeaconState { epoch: 1, }, latest_block_header: fixtures::beacon_block_header(7), - block_roots: fixtures::vector(H256::repeat_byte(0x77)), - state_roots: fixtures::vector(H256::repeat_byte(0x88)), - historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + block_roots: fixtures::tree_vector(H256::repeat_byte(0x77)), + state_roots: fixtures::tree_vector(H256::repeat_byte(0x88)), + historical_roots: ethlambda_ssz_tree::List::try_from(vec![H256::repeat_byte(0x99)]) .expect("within capacity"), eth1_data: fixtures::eth1_data(8), - eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_data_votes: ethlambda_ssz_tree::List::try_from(vec![fixtures::eth1_data(9)]) + .expect("within capacity"), eth1_deposit_index: 3, validators: shared::Validators::try_from(vec![fixtures::validator(10)]) .expect("within capacity"), balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), - randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), - slashings: fixtures::vector(1_000_000_000u64), + randao_mixes: fixtures::tree_vector(H256::repeat_byte(0xaa)), + slashings: fixtures::tree_vector(1_000_000_000u64), previous_epoch_participation: SszList::try_from(vec![7u8]).expect("within capacity"), current_epoch_participation: SszList::try_from(vec![9u8]).expect("within capacity"), justification_bits: { @@ -960,13 +975,13 @@ fn deneb_state() -> deneb::BeaconState { previous_justified_checkpoint: fixtures::checkpoint(13), current_justified_checkpoint: fixtures::checkpoint(14), finalized_checkpoint: fixtures::checkpoint(15), - inactivity_scores: SszList::try_from(vec![4u64]).expect("within capacity"), + inactivity_scores: vec![4u64].try_into().expect("within capacity"), current_sync_committee: sync_committee(16), next_sync_committee: sync_committee(17), latest_execution_payload_header: deneb_execution_payload_header(30), next_withdrawal_index: 40, next_withdrawal_validator_index: 41, - historical_summaries: SszList::try_from(vec![shared::HistoricalSummary { + historical_summaries: ethlambda_ssz_tree::List::try_from(vec![shared::HistoricalSummary { block_summary_root: H256::repeat_byte(0xbb), state_summary_root: H256::repeat_byte(0xcc), }]) @@ -1132,18 +1147,19 @@ fn electra_state() -> electra::BeaconState { epoch: 1, }, latest_block_header: fixtures::beacon_block_header(7), - block_roots: fixtures::vector(H256::repeat_byte(0x77)), - state_roots: fixtures::vector(H256::repeat_byte(0x88)), - historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + block_roots: fixtures::tree_vector(H256::repeat_byte(0x77)), + state_roots: fixtures::tree_vector(H256::repeat_byte(0x88)), + historical_roots: ethlambda_ssz_tree::List::try_from(vec![H256::repeat_byte(0x99)]) .expect("within capacity"), eth1_data: fixtures::eth1_data(8), - eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_data_votes: ethlambda_ssz_tree::List::try_from(vec![fixtures::eth1_data(9)]) + .expect("within capacity"), eth1_deposit_index: 3, validators: shared::Validators::try_from(vec![fixtures::validator(10)]) .expect("within capacity"), balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), - randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), - slashings: fixtures::vector(1_000_000_000u64), + randao_mixes: fixtures::tree_vector(H256::repeat_byte(0xaa)), + slashings: fixtures::tree_vector(1_000_000_000u64), previous_epoch_participation: SszList::try_from(vec![7u8]).expect("within capacity"), current_epoch_participation: SszList::try_from(vec![9u8]).expect("within capacity"), justification_bits: { @@ -1154,13 +1170,13 @@ fn electra_state() -> electra::BeaconState { previous_justified_checkpoint: fixtures::checkpoint(13), current_justified_checkpoint: fixtures::checkpoint(14), finalized_checkpoint: fixtures::checkpoint(15), - inactivity_scores: SszList::try_from(vec![4u64]).expect("within capacity"), + inactivity_scores: vec![4u64].try_into().expect("within capacity"), current_sync_committee: sync_committee(16), next_sync_committee: sync_committee(17), latest_execution_payload_header: deneb_execution_payload_header(30), next_withdrawal_index: 40, next_withdrawal_validator_index: 41, - historical_summaries: SszList::try_from(vec![shared::HistoricalSummary { + historical_summaries: ethlambda_ssz_tree::List::try_from(vec![shared::HistoricalSummary { block_summary_root: H256::repeat_byte(0xbb), state_summary_root: H256::repeat_byte(0xcc), }]) @@ -1222,18 +1238,19 @@ fn fulu_state() -> fulu::BeaconState { epoch: 1, }, latest_block_header: fixtures::beacon_block_header(7), - block_roots: fixtures::vector(H256::repeat_byte(0x77)), - state_roots: fixtures::vector(H256::repeat_byte(0x88)), - historical_roots: SszList::try_from(vec![H256::repeat_byte(0x99)]) + block_roots: fixtures::tree_vector(H256::repeat_byte(0x77)), + state_roots: fixtures::tree_vector(H256::repeat_byte(0x88)), + historical_roots: ethlambda_ssz_tree::List::try_from(vec![H256::repeat_byte(0x99)]) .expect("within capacity"), eth1_data: fixtures::eth1_data(8), - eth1_data_votes: SszList::try_from(vec![fixtures::eth1_data(9)]).expect("within capacity"), + eth1_data_votes: ethlambda_ssz_tree::List::try_from(vec![fixtures::eth1_data(9)]) + .expect("within capacity"), eth1_deposit_index: 3, validators: shared::Validators::try_from(vec![fixtures::validator(10)]) .expect("within capacity"), balances: shared::Balances::try_from(vec![32_000_000_001u64]).expect("within capacity"), - randao_mixes: fixtures::vector(H256::repeat_byte(0xaa)), - slashings: fixtures::vector(1_000_000_000u64), + randao_mixes: fixtures::tree_vector(H256::repeat_byte(0xaa)), + slashings: fixtures::tree_vector(1_000_000_000u64), previous_epoch_participation: SszList::try_from(vec![7u8]).expect("within capacity"), current_epoch_participation: SszList::try_from(vec![9u8]).expect("within capacity"), justification_bits: { @@ -1244,13 +1261,13 @@ fn fulu_state() -> fulu::BeaconState { previous_justified_checkpoint: fixtures::checkpoint(13), current_justified_checkpoint: fixtures::checkpoint(14), finalized_checkpoint: fixtures::checkpoint(15), - inactivity_scores: SszList::try_from(vec![4u64]).expect("within capacity"), + inactivity_scores: vec![4u64].try_into().expect("within capacity"), current_sync_committee: sync_committee(16), next_sync_committee: sync_committee(17), latest_execution_payload_header: deneb_execution_payload_header(30), next_withdrawal_index: 40, next_withdrawal_validator_index: 41, - historical_summaries: SszList::try_from(vec![shared::HistoricalSummary { + historical_summaries: ethlambda_ssz_tree::List::try_from(vec![shared::HistoricalSummary { block_summary_root: H256::repeat_byte(0xbb), state_summary_root: H256::repeat_byte(0xcc), }]) diff --git a/crates/net/p2p/src/beacon/mod.rs b/crates/net/p2p/src/beacon/mod.rs index c4002f682..70d7680c3 100644 --- a/crates/net/p2p/src/beacon/mod.rs +++ b/crates/net/p2p/src/beacon/mod.rs @@ -22,6 +22,7 @@ pub mod fork_schedule; pub mod handler; pub mod messages; pub mod protocols; +pub mod scoring; pub mod subnets; pub mod swarm; pub mod sync_committee; diff --git a/crates/net/p2p/src/beacon/scoring.rs b/crates/net/p2p/src/beacon/scoring.rs new file mode 100644 index 000000000..4f17065cb --- /dev/null +++ b/crates/net/p2p/src/beacon/scoring.rs @@ -0,0 +1,1474 @@ +//! Gossipsub peer scoring on the beacon wire. +//! +//! Lighthouse's parameters (`lighthouse_network/src/service/gossipsub_scoring_parameters.rs`), +//! ported formula for formula. Grandine, Teku and Lodestar run the same +//! constants, so a peer is scored here the way most of mainnet scores it, and +//! the tests pin this port to the numbers lighthouse's formulas give. What is +//! scored: +//! +//! | topic | weight | mesh deliveries (P3) | +//! |---|---|---| +//! | `beacon_block` | 0.5 | scored | +//! | `beacon_aggregate_and_proof` | 0.5 | scored | +//! | `beacon_attestation_{subnet_id}`, every subnet | 1/64 each | scored | +//! | `voluntary_exit`, `proposer_slashing`, `attester_slashing` | 0.05 each | off | +//! +//! Every subnet is given parameters, not only the backbone ones, because an +//! aggregator joins other subnets at runtime and the parameters have to be in +//! place before the mesh forms. +//! +//! Nothing else this node subscribes to has topic parameters, so only the +//! topic-independent terms (IP colocation, broken IWANT promises, slow peers) +//! see that traffic. `data_column_sidecar_{subnet_id}` is left out, as every +//! client but Prysm leaves it out. `sync_committee_contribution_and_proof` and +//! `bls_to_execution_change` are left out because this node has no consumer +//! for either and `Ignore`s every message on them. A delivery is only credited +//! once it is accepted, so mesh-delivery scoring there would starve every mesh +//! peer for a gap that is this node's own. +//! +//! # Where this differs from lighthouse +//! +//! - `mesh_n` is this node's own ([`crate::MESH_N`]) rather than the 5 that +//! lighthouse's default network load gives. It only enters the +//! first-message-delivery cap, which is two mesh shares of the expected +//! traffic. +//! - Mesh-delivery scoring also waits for this node to keep up with the chain: +//! see [`MeshDeliveryGate`]. +//! +//! # Refreshing +//! +//! The expected message rates depend on the active validator count, so the +//! block, aggregate and attestation parameters are rebuilt once per slot +//! ([`refresh`]) from the head's current-epoch shuffling, under every held +//! fork digest. Until the first refresh they are built for +//! [`PLACEHOLDER_ACTIVE_VALIDATORS`] with P3 off, the same placeholder +//! lighthouse starts from. +//! +//! # Fork digests +//! +//! Around a boundary the node holds two digests' topics (see +//! [`super::fork_schedule`]). Every held digest gets the whole parameter set, +//! scored by its [`DigestRole`]: +//! +//! | role | topic weights | mesh deliveries (P3) | +//! |---|---|---| +//! | upcoming, joined [`SUBSCRIBE_LEAD_EPOCHS`] ahead | as above | off | +//! | current | as above | per [`MeshDeliveryGate`] | +//! | previous, held [`UNSUBSCRIBE_LAG_EPOCHS`] after | 0 | off | +//! +//! [`SUBSCRIBE_LEAD_EPOCHS`]: super::fork_schedule::SUBSCRIBE_LEAD_EPOCHS +//! [`UNSUBSCRIBE_LAG_EPOCHS`]: super::fork_schedule::UNSUBSCRIBE_LAG_EPOCHS +//! +//! - The parameters reach the swarm before the topics are joined +//! ([`super::transition::apply`]). Gossipsub tracks a mesh peer on a topic +//! only if the topic had parameters when the peer was grafted: parameters +//! installed later do not reach a mesh that already exists. +//! - The previous digest's traffic moves away at the boundary, so its delivery +//! counters drain. Scored at its weight, the aggregate topic alone would put +//! an honest mesh peer below the graylist before the topic is left, and +//! leaving it would charge every mesh peer the sticky mesh-failure penalty. +//! Lighthouse zeroes the same weights (`remove_topic_weight_except`). +//! - A switch restarts the gate's warmup. The new digest's counters start +//! empty, while its mesh, formed before the boundary, can already be past +//! the deliveries' activation. Lighthouse joins two slots ahead, so +//! activation covers the refill there; this node joins whole epochs ahead. + +use std::cmp::Ordering; +use std::collections::HashSet; +use std::time::Duration; + +use ethlambda_state_transition::beacon::helpers::accessors::committee_count_per_slot; +use ethlambda_types::beacon::config::Config; +use ethlambda_types::beacon::constants::TARGET_AGGREGATORS_PER_COMMITTEE; +use ethlambda_types::beacon::preset::SLOTS_PER_EPOCH; +use ethlambda_types::beacon::primitives::ForkDigest; +use libp2p::gossipsub::{IdentTopic, PeerScoreParams, PeerScoreThresholds, TopicScoreParams}; +use tracing::info; + +use crate::beacon::constants::ATTESTATION_SUBNET_COUNT; +use crate::beacon::topics::{ + ATTESTER_SLASHING, BEACON_AGGREGATE_AND_PROOF, BEACON_BLOCK, PROPOSER_SLASHING, VOLUNTARY_EXIT, + attestation_topic_name, topic_name, +}; +use crate::gossipsub::beacon_wall_slot; +use crate::swarm_adapter::SwarmHandle; +use crate::{P2PServer, metrics}; + +/// The most a topic's time-in-mesh term (P1) contributes, before its weight. +const MAX_IN_MESH_SCORE: f64 = 10.0; +/// The most a topic's first-message-delivery term (P2) contributes, before +/// its weight. +const MAX_FIRST_MESSAGE_DELIVERIES_SCORE: f64 = 40.0; + +const BEACON_BLOCK_WEIGHT: f64 = 0.5; +const BEACON_AGGREGATE_PROOF_WEIGHT: f64 = 0.5; +const VOLUNTARY_EXIT_WEIGHT: f64 = 0.05; +const PROPOSER_SLASHING_WEIGHT: f64 = 0.05; +const ATTESTER_SLASHING_WEIGHT: f64 = 0.05; + +/// How late after the first delivery a mesh peer's copy still counts towards +/// its mesh deliveries: the time a hostile peer would need to replay a message +/// this node just forwarded to it and be credited for it. +const MESH_MESSAGE_DELIVERIES_WINDOW: Duration = Duration::from_secs(2); + +/// A counter decayed below this is treated as zero. +const DECAY_TO_ZERO: f64 = 0.01; + +/// Below this score a peer gets no IHAVE/IWANT gossip from this node. +pub const GOSSIP_THRESHOLD: f64 = -4000.0; +/// Below this score a peer is left out of this node's publishes. +pub const PUBLISH_THRESHOLD: f64 = -8000.0; +/// Below this score gossipsub ignores every RPC the peer sends, and +/// [`crate::swarm_adapter`] disconnects it. +pub const GRAYLIST_THRESHOLD: f64 = -16000.0; + +/// The active validator count the parameters are built for before the head +/// has named one: lighthouse's `minimum_validator_count`, one per slot. +pub const PLACEHOLDER_ACTIVE_VALIDATORS: u64 = SLOTS_PER_EPOCH; + +/// Head lag, in slots, beyond which [`MeshDeliveryGate`] switches mesh-delivery +/// scoring off. +/// +/// Two slots behind is already enough to `Ignore` most of what arrives, +/// since current aggregates and attestations vote for a block this node has +/// not imported. The margin is set by how long the delivery counters can go +/// without credit before a penalty starts. The aggregate counter falls from +/// its cap to the threshold in about 19 slots, and the other scored topics +/// take longer, so closing the gate within a few slots of losing the head +/// leaves nothing to penalize. Empty slots count towards the lag as well, +/// which is harmless: those votes are for a block this node has. +pub const MESH_DELIVERY_MAX_HEAD_LAG: u64 = 4; + +/// How many slots the head has to stay within [`MESH_DELIVERY_MAX_HEAD_LAG`] +/// before [`MeshDeliveryGate`] switches mesh-delivery scoring back on. +/// +/// While the gate was closed the counters kept counting, but a catch-up +/// credits nothing, so they come out of it near zero. Reopening the moment the +/// head catches up would score the whole mesh on that empty history. Every +/// scored topic refills to its threshold within a few slots of accepted +/// traffic, so one epoch leaves ample margin. +pub const MESH_DELIVERY_WARMUP_SLOTS: u64 = SLOTS_PER_EPOCH; + +/// The score thresholds, which do not depend on the chain. +pub fn thresholds() -> PeerScoreThresholds { + PeerScoreThresholds { + gossip_threshold: GOSSIP_THRESHOLD, + publish_threshold: PUBLISH_THRESHOLD, + graylist_threshold: GRAYLIST_THRESHOLD, + // Above what any peer can reach (the topic score cap), so peer + // exchange is never accepted, as on lighthouse. + accept_px_threshold: 100.0, + opportunistic_graft_threshold: 5.0, + } +} + +/// What the dynamic topic parameters are rebuilt from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScoreInputs { + /// Active validators in the head's current epoch. + pub active_validators: u64, + /// The wall-clock slot. Only matters on a young chain: a topic's + /// mesh-delivery scoring stays off until the chain is older than that + /// topic's decay window, as on lighthouse. + pub current_slot: u64, + /// [`MeshDeliveryGate`]'s verdict. + pub mesh_deliveries_scored: bool, +} + +impl ScoreInputs { + /// What the parameters are built from before the first refresh: a + /// placeholder validator count, and no mesh-delivery scoring. + pub fn placeholder() -> Self { + Self { + active_validators: PLACEHOLDER_ACTIVE_VALIDATORS, + current_slot: 0, + mesh_deliveries_scored: false, + } + } +} + +/// Where a held fork digest stands against the one this node publishes +/// under, which decides how its topics are scored. See the module +/// documentation's "Fork digests". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DigestRole { + /// Left behind by the last boundary and held a while longer. Weighs + /// nothing, since its traffic has moved to the current digest. + Previous, + /// The digest this node publishes under, and the only one whose mesh + /// deliveries can be scored. + Current, + /// Joined ahead of its boundary. Nothing is published under it yet, so + /// its mesh deliveries are not scored. + Upcoming, +} + +/// A topic's mesh-delivery (P3) setup: lighthouse's `mesh_message_info`. +struct MeshDeliveries { + /// How many slots the delivery counter takes to decay to zero. + decay_slots: u64, + /// The counter's cap, as a multiple of its threshold. + cap_factor: f64, + /// How long a peer is in the mesh before its deficit counts. + activation: Duration, +} + +/// The chain-derived constants every parameter is computed from, fixed for +/// the life of the node. +#[derive(Debug, Clone)] +pub struct ScoreSettings { + slot: Duration, + epoch: Duration, + decay_interval: Duration, + mesh_n: usize, + attestation_subnet_weight: f64, + /// The most every scored topic's P1 and P2 can add up to, the unit the + /// invalid-message penalty is sized in. + max_positive_score: f64, +} + +impl ScoreSettings { + pub fn new(config: &Config, mesh_n: usize) -> Self { + let slot = Duration::from_millis(config.slot_duration_ms); + let attestation_subnet_weight = 1.0 / ATTESTATION_SUBNET_COUNT as f64; + let max_positive_score = (MAX_IN_MESH_SCORE + MAX_FIRST_MESSAGE_DELIVERIES_SCORE) + * (BEACON_BLOCK_WEIGHT + + BEACON_AGGREGATE_PROOF_WEIGHT + + attestation_subnet_weight * ATTESTATION_SUBNET_COUNT as f64 + + VOLUNTARY_EXIT_WEIGHT + + PROPOSER_SLASHING_WEIGHT + + ATTESTER_SLASHING_WEIGHT); + Self { + slot, + epoch: slot * SLOTS_PER_EPOCH as u32, + // One decay tick per slot, which is what every expected rate + // below is stated per. + decay_interval: slot.max(Duration::from_secs(1)), + mesh_n, + attestation_subnet_weight, + max_positive_score, + } + } + + /// The whole parameter set: the global terms, and every scored topic + /// under `fork_digest` as the current digest. The digests a fork schedule + /// adds later are parametrized by [`PeerScoring::hold`]. + pub fn peer_score_params( + &self, + fork_digest: ForkDigest, + inputs: ScoreInputs, + ) -> PeerScoreParams { + let behaviour_penalty_decay = self.decay(self.epoch * 10); + // The weight puts a peer that keeps earning ten behaviour penalties + // an epoch exactly at the gossip threshold once its counter settles. + let behaviour_penalty_threshold = 6.0; + let settled_excess = + decay_convergence(behaviour_penalty_decay, 10.0 / SLOTS_PER_EPOCH as f64) + - behaviour_penalty_threshold; + let topic_score_cap = self.max_positive_score * 0.5; + + let topics = self + .digest_topic_params(fork_digest, DigestRole::Current, inputs) + .into_iter() + .map(|(topic, params)| (topic.hash(), params)) + .collect(); + + PeerScoreParams { + topics, + topic_score_cap, + // Lighthouse's value. This node never sets an application score, + // so the term is always zero. + app_specific_weight: 1.0, + ip_colocation_factor_weight: -topic_score_cap, + // Up to eight peers per IP before the penalty starts. + ip_colocation_factor_threshold: 8.0, + ip_colocation_factor_whitelist: HashSet::new(), + behaviour_penalty_weight: GOSSIP_THRESHOLD / settled_excess.powi(2), + behaviour_penalty_threshold, + behaviour_penalty_decay, + decay_interval: self.decay_interval, + decay_to_zero: DECAY_TO_ZERO, + retain_score: self.epoch * 100, + // A peer whose send queue is full, or whose queued messages time + // out, loses ten a time, mostly gone by the next tick. + slow_peer_weight: -10.0, + slow_peer_threshold: 0.0, + slow_peer_decay: 0.1, + } + } + + /// Every scored topic under `fork_digest`, for a digest in `role`. + /// + /// `inputs.mesh_deliveries_scored` only reaches the current digest. The + /// rest are built at their real rates, so their caps match the current + /// digest's, and a previous digest then has its weights zeroed. + pub fn digest_topic_params( + &self, + fork_digest: ForkDigest, + role: DigestRole, + inputs: ScoreInputs, + ) -> Vec<(IdentTopic, TopicScoreParams)> { + let inputs = ScoreInputs { + mesh_deliveries_scored: inputs.mesh_deliveries_scored && role == DigestRole::Current, + ..inputs + }; + let mut topics = self.fixed_topic_params(fork_digest); + topics.extend(self.dynamic_topic_params(fork_digest, inputs)); + if role == DigestRole::Previous { + // Zeroed after the build rather than passed in as the weight: + // the invalid-message weight divides by it. + for (_, params) in &mut topics { + params.topic_weight = 0.0; + } + } + topics + } + + /// Exits and slashings: rare enough that their rates do not depend on + /// the validator count, and never mesh-delivery scored. + fn fixed_topic_params(&self, fork_digest: ForkDigest) -> Vec<(IdentTopic, TopicScoreParams)> { + let per_slot = |per_epoch: f64| per_epoch / SLOTS_PER_EPOCH as f64; + [ + (VOLUNTARY_EXIT, VOLUNTARY_EXIT_WEIGHT, per_slot(4.0)), + ( + ATTESTER_SLASHING, + ATTESTER_SLASHING_WEIGHT, + per_slot(1.0 / 5.0), + ), + ( + PROPOSER_SLASHING, + PROPOSER_SLASHING_WEIGHT, + per_slot(1.0 / 5.0), + ), + ] + .into_iter() + .map(|(kind, weight, rate)| { + let topic = IdentTopic::new(topic_name(fork_digest, kind)); + let params = self.topic_params(weight, rate, self.epoch * 100, None, false); + (topic, params) + }) + .collect() + } + + /// Blocks, aggregates and every attestation subnet: the topics whose + /// expected traffic follows the validator count, and so the ones + /// [`refresh`] rebuilds. + pub fn dynamic_topic_params( + &self, + fork_digest: ForkDigest, + inputs: ScoreInputs, + ) -> Vec<(IdentTopic, TopicScoreParams)> { + // Zero would leave every rate zero and every first-delivery cap with + // it, which gossipsub rejects. + let active = inputs.active_validators.max(PLACEHOLDER_ACTIVE_VALIDATORS); + let (aggregators_per_slot, committees_per_slot) = expected_aggregators_per_slot(active); + // Whether each subnet sees more than one committee's burst an epoch, + // which shortens its decay windows. + let multiple_bursts_per_subnet_per_epoch = + committees_per_slot >= 2 * ATTESTATION_SUBNET_COUNT / SLOTS_PER_EPOCH; + // Lighthouse's young-chain rule and this node's sync gate: P3 needs + // both a chain older than the topic's decay window and a node that + // has kept up with it. + let scored = + |decay_slots: u64| inputs.mesh_deliveries_scored && inputs.current_slot > decay_slots; + + let block_mesh = MeshDeliveries { + decay_slots: SLOTS_PER_EPOCH * 5, + cap_factor: 3.0, + activation: self.epoch, + }; + let block = self.topic_params( + BEACON_BLOCK_WEIGHT, + 1.0, + self.epoch * 20, + Some(&block_mesh), + scored(block_mesh.decay_slots), + ); + + let aggregate_mesh = MeshDeliveries { + decay_slots: SLOTS_PER_EPOCH * 2, + cap_factor: 4.0, + activation: self.epoch, + }; + let aggregate = self.topic_params( + BEACON_AGGREGATE_PROOF_WEIGHT, + aggregators_per_slot, + self.epoch, + Some(&aggregate_mesh), + scored(aggregate_mesh.decay_slots), + ); + + let attestation_mesh = if multiple_bursts_per_subnet_per_epoch { + MeshDeliveries { + decay_slots: SLOTS_PER_EPOCH * 4, + cap_factor: 16.0, + activation: self.slot * (SLOTS_PER_EPOCH as u32 / 2 + 1), + } + } else { + MeshDeliveries { + decay_slots: SLOTS_PER_EPOCH * 16, + cap_factor: 16.0, + activation: self.epoch * 3, + } + }; + let attestation_fmd_decay_time = if multiple_bursts_per_subnet_per_epoch { + self.epoch + } else { + self.epoch * 4 + }; + let attestation = self.topic_params( + self.attestation_subnet_weight, + active as f64 / ATTESTATION_SUBNET_COUNT as f64 / SLOTS_PER_EPOCH as f64, + attestation_fmd_decay_time, + Some(&attestation_mesh), + scored(attestation_mesh.decay_slots), + ); + + let mut topics = vec![ + ( + IdentTopic::new(topic_name(fork_digest, BEACON_BLOCK)), + block, + ), + ( + IdentTopic::new(topic_name(fork_digest, BEACON_AGGREGATE_AND_PROOF)), + aggregate, + ), + ]; + topics.extend((0..ATTESTATION_SUBNET_COUNT).map(|subnet_id| { + let topic = IdentTopic::new(attestation_topic_name(fork_digest, subnet_id)); + (topic, attestation.clone()) + })); + topics + } + + /// One topic's parameters: lighthouse's `get_topic_params`. + /// + /// `rate` is the topic's expected messages per slot, one decay tick. + /// With `mesh_scored` false the mesh-delivery threshold and weight are + /// zero, so no deficit can build and no P3b is charged at prune, but the + /// cap stays where the threshold would put it: the counters keep + /// counting to their real ceiling, so switching P3 on later scores the + /// history that was actually delivered. + fn topic_params( + &self, + topic_weight: f64, + rate: f64, + first_message_decay_time: Duration, + mesh: Option<&MeshDeliveries>, + mesh_scored: bool, + ) -> TopicScoreParams { + let time_in_mesh_cap = 3600.0 / self.slot.as_secs_f64(); + let first_message_deliveries_decay = self.decay(first_message_decay_time); + // Two mesh shares of the traffic that arrives over one decay window. + let first_message_deliveries_cap = decay_convergence( + first_message_deliveries_decay, + 2.0 * rate / self.mesh_n as f64, + ); + + let mut params = TopicScoreParams { + topic_weight, + time_in_mesh_weight: MAX_IN_MESH_SCORE / time_in_mesh_cap, + time_in_mesh_quantum: self.slot, + time_in_mesh_cap, + first_message_deliveries_weight: MAX_FIRST_MESSAGE_DELIVERIES_SCORE + / first_message_deliveries_cap, + first_message_deliveries_decay, + first_message_deliveries_cap, + mesh_message_deliveries_weight: 0.0, + mesh_message_deliveries_decay: 0.0, + mesh_message_deliveries_cap: 0.0, + mesh_message_deliveries_threshold: 0.0, + mesh_message_deliveries_window: Duration::ZERO, + mesh_message_deliveries_activation: Duration::ZERO, + mesh_failure_penalty_weight: 0.0, + mesh_failure_penalty_decay: 0.0, + // One invalid message costs a whole `max_positive_score`, on any + // topic, whatever its weight. + invalid_message_deliveries_weight: -self.max_positive_score / topic_weight, + invalid_message_deliveries_decay: self.decay(self.epoch * 50), + }; + + if let Some(mesh) = mesh { + let decay = self.decay(self.slot * mesh.decay_slots as u32); + // Two percent of the expected traffic, over the decay window. + let threshold = decay_convergence(decay, rate / 50.0) * decay; + params.mesh_message_deliveries_decay = decay; + params.mesh_message_deliveries_cap = (mesh.cap_factor * threshold).max(2.0); + params.mesh_message_deliveries_activation = mesh.activation; + params.mesh_message_deliveries_window = MESH_MESSAGE_DELIVERIES_WINDOW; + params.mesh_failure_penalty_decay = decay; + params.mesh_failure_penalty_weight = -topic_weight; + if mesh_scored { + params.mesh_message_deliveries_threshold = threshold; + params.mesh_message_deliveries_weight = -topic_weight; + } + } + + params + } + + /// The per-tick factor that takes a counter from one to + /// [`DECAY_TO_ZERO`] over `decay_time`. + fn decay(&self, decay_time: Duration) -> f64 { + let ticks = decay_time.as_secs_f64() / self.decay_interval.as_secs_f64(); + DECAY_TO_ZERO.powf(1.0 / ticks) + } +} + +/// Where a counter that gains `rate` a tick and decays by `decay` a tick +/// settles. +fn decay_convergence(decay: f64, rate: f64) -> f64 { + rate / (1.0 - decay) +} + +/// Aggregates expected per slot with `active` validators, and the committee +/// count per slot it was derived from. +/// +/// Every committee selects `len / max(1, len / TARGET_AGGREGATORS_PER_COMMITTEE)` +/// aggregators in expectation (phase0's `is_aggregator`), and an epoch's +/// committees differ in size by at most one member. +fn expected_aggregators_per_slot(active: u64) -> (f64, u64) { + let committees_per_slot = committee_count_per_slot(active); + let committees = committees_per_slot * SLOTS_PER_EPOCH; + let smaller = active / committees; + let larger_count = active - smaller * committees; + let smaller_modulo = (smaller / TARGET_AGGREGATORS_PER_COMMITTEE).max(1); + let larger_modulo = ((smaller + 1) / TARGET_AGGREGATORS_PER_COMMITTEE).max(1); + let per_epoch = ((committees - larger_count) * smaller) as f64 / smaller_modulo as f64 + + (larger_count * (smaller + 1)) as f64 / larger_modulo as f64; + (per_epoch / SLOTS_PER_EPOCH as f64, committees_per_slot) +} + +/// Whether mesh-delivery scoring (P3) is safe to switch on: the head has +/// kept up with the wall clock long enough that the delivery counters +/// reflect what mesh peers sent, not what this node was able to accept. +/// +/// A delivery is credited only once it is accepted. While this node catches +/// up it `Ignore`s every aggregate and attestation voting for a block it has +/// not imported, so no mesh peer is credited for anything. Aggregate traffic +/// is heavy enough that a mesh peer credited with nothing scores below the +/// graylist. Without the gate, a restart would graylist the whole aggregate +/// mesh because of this node's lag, not anything the peers did. +/// +/// Lighthouse avoids the same trap differently: it joins these topics only +/// once it has synced, so it has no mesh while it catches up. This node +/// subscribes at startup. The gate also covers the case lighthouse leaves +/// open, a node that falls behind after it has synced. +#[derive(Debug, Default)] +pub struct MeshDeliveryGate { + /// The wall-clock slot since which the head has stayed within + /// [`MESH_DELIVERY_MAX_HEAD_LAG`], or `None` while it lags further. + keeping_up_since: Option, +} + +impl MeshDeliveryGate { + /// Record where the head is, and return whether mesh deliveries should + /// be scored. + pub fn update(&mut self, wall_slot: u64, head_slot: u64) -> bool { + if wall_slot.saturating_sub(head_slot) > MESH_DELIVERY_MAX_HEAD_LAG { + self.keeping_up_since = None; + return false; + } + let since = *self.keeping_up_since.get_or_insert(wall_slot); + // Saturating: the wall clock can step backwards under an NTP correction. + wall_slot.saturating_sub(since) >= MESH_DELIVERY_WARMUP_SLOTS + } + + /// Start the warmup over, as if the head had just caught up: the + /// counters that the verdict reads have been emptied. + pub fn restart(&mut self) { + self.keeping_up_since = None; + } +} + +/// The scoring state [`P2PServer`] keeps on a beacon wire. +pub(crate) struct PeerScoring { + settings: ScoreSettings, + gate: MeshDeliveryGate, + /// The last active validator count the head named, kept for the ticks + /// where its shuffling is not built yet. + active_validators: u64, + /// The gate's last verdict, so a change is logged once. + mesh_deliveries_scored: bool, + /// The fork digests whose topics the node holds, in activation order. + held: Vec, + /// The one among [`Self::held`] this node publishes under. + current: ForkDigest, +} + +impl PeerScoring { + /// `fork_digest` is the digest the swarm was built on, the only one held + /// until the fork schedule is first applied. + pub(crate) fn new(config: &Config, fork_digest: ForkDigest) -> Self { + Self { + settings: ScoreSettings::new(config, crate::MESH_N), + gate: MeshDeliveryGate::default(), + active_validators: PLACEHOLDER_ACTIVE_VALIDATORS, + mesh_deliveries_scored: false, + held: vec![fork_digest], + current: fork_digest, + } + } + + /// Record the digests the node now holds, in activation order, and which + /// one is current, then hand the swarm every one's parameters. + /// + /// Called by [`super::transition::apply`] before it joins any topic, so + /// a digest is parametrized before its first GRAFT. Both travel the + /// swarm's one command channel, so sending these first is enough. + pub(crate) fn hold( + &mut self, + held: Vec, + current: ForkDigest, + wall_slot: u64, + swarm: &SwarmHandle, + ) { + if current != self.current { + // The new digest's counters are empty and its mesh may already + // be past the deliveries' activation, so scoring it now would + // graylist its whole aggregate mesh before the first aggregate. + self.gate.restart(); + if self.mesh_deliveries_scored { + info!( + fork_digest = %hex::encode(current), + "Gossipsub mesh-delivery scoring paused while the new fork digest warms up" + ); + self.mesh_deliveries_scored = false; + metrics::set_gossipsub_mesh_delivery_scoring(false); + } + self.current = current; + } + self.held = held; + self.install(wall_slot, swarm); + } + + /// Hand the swarm the parameters of every held digest, each by its role. + fn install(&self, wall_slot: u64, swarm: &SwarmHandle) { + let inputs = ScoreInputs { + active_validators: self.active_validators, + current_slot: wall_slot, + mesh_deliveries_scored: self.mesh_deliveries_scored, + }; + // `apply` checks that the current digest is held before it calls + // `hold`, so `None` (every digest previous) is never reached. + let current_index = self.held.iter().position(|&digest| digest == self.current); + let topics = self + .held + .iter() + .enumerate() + .flat_map(|(index, &digest)| { + let role = match current_index.map(|current| index.cmp(¤t)) { + Some(Ordering::Equal) => DigestRole::Current, + Some(Ordering::Greater) => DigestRole::Upcoming, + Some(Ordering::Less) | None => DigestRole::Previous, + }; + self.settings.digest_topic_params(digest, role, inputs) + }) + .collect(); + swarm.set_topic_score_params(topics); + } +} + +/// Rebuild every held digest's topic parameters from the head and hand them +/// to the swarm. Beacon only; a no-op on lean, which runs without peer +/// scoring. +pub(crate) fn refresh(server: &mut P2PServer) { + let (Some(wire), Some(scoring)) = (server.wire.beacon(), server.peer_scoring.as_mut()) else { + return; + }; + let wall_slot = beacon_wall_slot(wire); + let head_slot = server.store.beacon_head().map_or(0, |(slot, _)| slot); + let mesh_deliveries_scored = scoring.gate.update(wall_slot, head_slot); + if mesh_deliveries_scored != scoring.mesh_deliveries_scored { + info!( + wall_slot, + head_slot, mesh_deliveries_scored, "Gossipsub mesh-delivery scoring switched" + ); + scoring.mesh_deliveries_scored = mesh_deliveries_scored; + } + metrics::set_gossipsub_mesh_delivery_scoring(mesh_deliveries_scored); + + if let Some(committees) = server.store.committee_cache().head_current_committees() { + scoring.active_validators = committees.active_validator_count(); + } + + scoring.install(wall_slot, &server.swarm_handle); +} + +/// Which score band a peer falls in, for `lean_gossipsub_peers_by_score`. +/// +/// Mutually exclusive, so the bands add up to the peers gossipsub knows. +pub(crate) fn score_band(score: f64) -> &'static str { + if score >= 0.0 { + "non_negative" + } else if score >= GOSSIP_THRESHOLD { + "negative" + } else if score >= PUBLISH_THRESHOLD { + "below_gossip" + } else if score >= GRAYLIST_THRESHOLD { + "below_publish" + } else { + "below_graylist" + } +} + +/// Every band [`score_band`] can return, so a band that empties is published +/// as zero rather than keeping its last count. +pub(crate) const SCORE_BANDS: [&str; 5] = [ + "non_negative", + "negative", + "below_gossip", + "below_publish", + "below_graylist", +]; + +#[cfg(test)] +mod tests { + use super::*; + + use std::collections::HashMap; + + use ethlambda_types::beacon::config::BlobScheduleEntry; + use ethlambda_types::beacon::fork::ForkName; + use ethlambda_types::beacon::primitives::Root; + use futures::StreamExt; + use libp2p::gossipsub::TopicHash; + use libp2p::swarm::SwarmEvent; + use libssz_types::SszList; + use tokio::sync::mpsc::UnboundedReceiver; + + use crate::beacon::fork_schedule::{ForkSchedule, UNSUBSCRIBE_LAG_EPOCHS}; + use crate::beacon::swarm::BeaconWireConfig; + use crate::beacon::transition::apply; + use crate::swarm_adapter::{SwarmCommand, execute_command}; + use crate::test_support::unconnected_beacon_server; + use crate::{BuiltSwarm, LeanWireConfig, SwarmConfig, WireConfig, build_swarm}; + + const DIGEST: ForkDigest = [0x8c, 0x9f, 0x62, 0xfe]; + + /// Lighthouse's default network load gives `mesh_n` 5, the value every + /// number below was computed at on lighthouse v8.2.2's formulas. + const LIGHTHOUSE_MESH_N: usize = 5; + + fn settings(mesh_n: usize) -> ScoreSettings { + ScoreSettings::new(&Config::mainnet(), mesh_n) + } + + fn inputs(active_validators: u64, mesh_deliveries_scored: bool) -> ScoreInputs { + ScoreInputs { + active_validators, + // Far past every topic's decay window, like mainnet. + current_slot: 12_000_000, + mesh_deliveries_scored, + } + } + + fn assert_close(actual: f64, expected: f64, what: &str) { + let tolerance = expected.abs() * 1e-3; + assert!( + (actual - expected).abs() <= tolerance, + "{what}: {actual} is not {expected}" + ); + } + + fn topic<'a>(topics: &'a [(IdentTopic, TopicScoreParams)], kind: &str) -> &'a TopicScoreParams { + let name = topic_name(DIGEST, kind); + &topics + .iter() + .find(|(topic, _)| topic.to_string() == name) + .unwrap_or_else(|| panic!("{kind} has parameters")) + .1 + } + + /// The globals match lighthouse's, which is what makes the thresholds + /// mean what they mean on every other client. + #[test] + fn the_global_terms_are_lighthouses() { + let params = settings(LIGHTHOUSE_MESH_N).peer_score_params(DIGEST, inputs(1_000_000, true)); + assert_close(params.topic_score_cap, 53.75, "topic_score_cap"); + assert_close( + params.ip_colocation_factor_weight, + -53.75, + "ip colocation weight", + ); + assert_close(params.behaviour_penalty_decay, 0.985_712, "behaviour decay"); + assert_close(params.behaviour_penalty_weight, -15.879, "behaviour weight"); + assert_eq!(params.decay_interval, Duration::from_secs(12)); + assert_eq!(params.retain_score, Duration::from_secs(38_400)); + } + + /// The per-topic numbers at 1,000,000 active validators match + /// lighthouse's, computed from its own formulas. + #[test] + fn the_topic_terms_are_lighthouses() { + let topics = + settings(LIGHTHOUSE_MESH_N).dynamic_topic_params(DIGEST, inputs(1_000_000, true)); + + let block = topic(&topics, BEACON_BLOCK); + assert_close(block.first_message_deliveries_cap, 55.79, "block fmd cap"); + assert_close( + block.mesh_message_deliveries_threshold, + 0.685, + "block mmd threshold", + ); + assert_close( + block.invalid_message_deliveries_weight, + -215.0, + "block imd weight", + ); + + let aggregate = topic(&topics, BEACON_AGGREGATE_AND_PROOF); + assert_close( + aggregate.first_message_deliveries_cap, + 3108.6, + "aggregate fmd cap", + ); + assert_close( + aggregate.mesh_message_deliveries_threshold, + 279.24, + "aggregate threshold", + ); + assert_close( + aggregate.mesh_message_deliveries_cap, + 1116.95, + "aggregate mmd cap", + ); + + let attestation = topic(&topics, "beacon_attestation_0"); + assert_close( + attestation.first_message_deliveries_cap, + 1457.2, + "attestation fmd cap", + ); + assert_close( + attestation.mesh_message_deliveries_threshold, + 266.58, + "attestation threshold", + ); + assert_eq!( + attestation.mesh_message_deliveries_activation, + Duration::from_secs(204) + ); + assert_close( + attestation.invalid_message_deliveries_weight, + -6880.0, + "attestation imd", + ); + } + + /// Every combination gossipsub can be handed passes its own validation, + /// including the placeholder the swarm starts on, a registry of zero, and + /// every digest role's topics. + #[test] + fn every_parameter_set_is_valid() { + let settings = settings(crate::MESH_N); + for active in [ + 0, + 1, + PLACEHOLDER_ACTIVE_VALIDATORS, + 1_000, + 100_000, + 1_000_000, + 2_500_000, + ] { + for current_slot in [0, 100, 600, 12_000_000] { + for mesh_deliveries_scored in [false, true] { + let inputs = ScoreInputs { + active_validators: active, + current_slot, + mesh_deliveries_scored, + }; + settings + .peer_score_params(DIGEST, inputs) + .validate() + .unwrap_or_else(|err| panic!("{inputs:?}: {err}")); + for role in [DigestRole::Previous, DigestRole::Upcoming] { + for (topic, params) in settings.digest_topic_params(DIGEST, role, inputs) { + params + .validate() + .unwrap_or_else(|err| panic!("{role:?} {topic} {inputs:?}: {err}")); + } + } + } + } + } + thresholds().validate().expect("valid thresholds"); + } + + /// With the gate closed no deficit can build, but the cap stays at its + /// real value, so the counters are full when the gate opens again. + #[test] + fn a_closed_gate_zeroes_the_deficit_but_keeps_the_cap() { + let settings = settings(crate::MESH_N); + let open = settings.dynamic_topic_params(DIGEST, inputs(1_000_000, true)); + let closed = settings.dynamic_topic_params(DIGEST, inputs(1_000_000, false)); + for kind in [ + BEACON_BLOCK, + BEACON_AGGREGATE_AND_PROOF, + "beacon_attestation_7", + ] { + let (open, closed) = (topic(&open, kind), topic(&closed, kind)); + assert!( + open.mesh_message_deliveries_weight < 0.0, + "{kind} scored when open" + ); + assert_eq!(closed.mesh_message_deliveries_weight, 0.0, "{kind}"); + assert_eq!(closed.mesh_message_deliveries_threshold, 0.0, "{kind}"); + assert_eq!( + closed.mesh_message_deliveries_cap, open.mesh_message_deliveries_cap, + "{kind} keeps counting to its real cap" + ); + } + } + + /// Lighthouse's young-chain rule survives the port: an open gate still + /// leaves a topic unscored until the chain outlives its decay window. + #[test] + fn a_young_chain_leaves_mesh_deliveries_unscored() { + let young = ScoreInputs { + current_slot: SLOTS_PER_EPOCH * 2, + ..inputs(1_000_000, true) + }; + let topics = settings(crate::MESH_N).dynamic_topic_params(DIGEST, young); + assert_eq!( + topic(&topics, BEACON_BLOCK).mesh_message_deliveries_weight, + 0.0 + ); + assert_eq!( + topic(&topics, BEACON_AGGREGATE_AND_PROOF).mesh_message_deliveries_weight, + 0.0, + "the aggregate window is exactly two epochs, and the slot is not past it" + ); + } + + /// Every attestation subnet has parameters, not only the ones this node + /// backbones, since an aggregator joins others at runtime. + #[test] + fn every_attestation_subnet_is_scored() { + let params = settings(crate::MESH_N).peer_score_params(DIGEST, inputs(1_000_000, true)); + for subnet_id in 0..ATTESTATION_SUBNET_COUNT { + let topic = IdentTopic::new(attestation_topic_name(DIGEST, subnet_id)); + assert!( + params.topics.contains_key(&topic.hash()), + "subnet {subnet_id}" + ); + } + // Block, aggregate, three fixed topics and the 64 subnets. + assert_eq!( + params.topics.len(), + 2 + 3 + ATTESTATION_SUBNET_COUNT as usize + ); + } + + /// Mainnet's committees saturate at `MAX_COMMITTEES_PER_SLOT`, so about + /// sixteen aggregators each: lighthouse's figure of 1041.67 per slot. + #[test] + fn a_million_validators_expect_about_a_thousand_aggregators_a_slot() { + let (per_slot, committees_per_slot) = expected_aggregators_per_slot(1_000_000); + assert_eq!(committees_per_slot, 64); + assert_close(per_slot, 1041.67, "aggregators per slot"); + } + + #[test] + fn the_gate_opens_only_after_a_warmup_within_the_lag() { + let mut gate = MeshDeliveryGate::default(); + // Catching up: far behind. + assert!(!gate.update(1_000, 900)); + // Caught up, but the counters have not refilled yet. + assert!(!gate.update(1_001, 1_000)); + assert!(!gate.update( + 1_000 + MESH_DELIVERY_WARMUP_SLOTS, + 1_000 + MESH_DELIVERY_WARMUP_SLOTS + )); + assert!(gate.update( + 1_001 + MESH_DELIVERY_WARMUP_SLOTS, + 1_000 + MESH_DELIVERY_WARMUP_SLOTS + )); + // A lag within the bound, empty slots say, keeps it open. + let wall = 1_001 + MESH_DELIVERY_WARMUP_SLOTS + MESH_DELIVERY_MAX_HEAD_LAG; + assert!(gate.update(wall, 1_001 + MESH_DELIVERY_WARMUP_SLOTS)); + // Falling further behind closes it at once, and restarts the warmup. + assert!(!gate.update(wall + 1, 1_001 + MESH_DELIVERY_WARMUP_SLOTS)); + assert!(!gate.update(wall + 2, wall + 2)); + } + + /// A restart, which a fork digest switch triggers, closes an open gate + /// for a whole warmup even though the head never fell behind. + #[test] + fn a_restarted_gate_warms_up_again() { + let mut gate = MeshDeliveryGate::default(); + assert!(!gate.update(1_000, 1_000)); + let open = 1_000 + MESH_DELIVERY_WARMUP_SLOTS; + assert!(gate.update(open, open)); + + gate.restart(); + assert!(!gate.update(open + 1, open + 1)); + let warm = open + 1 + MESH_DELIVERY_WARMUP_SLOTS; + assert!(!gate.update(warm - 1, warm - 1)); + assert!(gate.update(warm, warm)); + } + + /// Only the current digest's mesh deliveries are scored, whatever the + /// gate says; a previous digest weighs nothing; and every role carries + /// the whole topic set, exits and slashings included. + #[test] + fn each_digest_role_is_scored_as_its_traffic_allows() { + let settings = settings(crate::MESH_N); + let inputs = inputs(1_000_000, true); + let current = settings.digest_topic_params(DIGEST, DigestRole::Current, inputs); + let upcoming = settings.digest_topic_params(DIGEST, DigestRole::Upcoming, inputs); + let previous = settings.digest_topic_params(DIGEST, DigestRole::Previous, inputs); + + for topics in [¤t, &upcoming, &previous] { + assert_eq!(topics.len(), 2 + 3 + ATTESTATION_SUBNET_COUNT as usize); + for kind in [VOLUNTARY_EXIT, PROPOSER_SLASHING, ATTESTER_SLASHING] { + topic(topics, kind); + } + } + for kind in [ + BEACON_BLOCK, + BEACON_AGGREGATE_AND_PROOF, + "beacon_attestation_7", + ] { + let current = topic(¤t, kind); + assert!(current.mesh_message_deliveries_weight < 0.0, "{kind}"); + let others = [ + ("upcoming", topic(&upcoming, kind)), + ("previous", topic(&previous, kind)), + ]; + for (role, params) in others { + assert_eq!(params.mesh_message_deliveries_weight, 0.0, "{role} {kind}"); + assert_eq!( + params.mesh_message_deliveries_threshold, 0.0, + "{role} {kind}" + ); + // The same caps, so a change of role trims no counter. + assert_eq!( + params.mesh_message_deliveries_cap, current.mesh_message_deliveries_cap, + "{role} {kind}" + ); + assert_eq!( + params.first_message_deliveries_cap, current.first_message_deliveries_cap, + "{role} {kind}" + ); + } + assert_eq!(topic(&upcoming, kind).topic_weight, current.topic_weight); + } + assert!( + previous + .iter() + .all(|(_, params)| params.topic_weight == 0.0) + ); + } + + /// One honest mesh peer on a topic whose traffic stopped at a boundary, + /// scored tick by tick the way the pinned gossipsub scores mesh + /// deliveries (`peer_score.rs`): the counter starts at its cap and only + /// decays. Returns the weighted P3 at every tick of the epochs the topic + /// is still held, and the P3b that leaving the topic then charges. + fn silent_mesh_penalties(params: &TopicScoreParams) -> (Vec, f64) { + let deficit = + |deliveries: f64| (params.mesh_message_deliveries_threshold - deliveries).max(0.0); + let mut deliveries = params.mesh_message_deliveries_cap; + let p3 = (0..UNSUBSCRIBE_LAG_EPOCHS * SLOTS_PER_EPOCH) + .map(|_| { + deliveries *= params.mesh_message_deliveries_decay; + if deliveries < DECAY_TO_ZERO { + deliveries = 0.0; + } + deficit(deliveries).powi(2) + * params.mesh_message_deliveries_weight + * params.topic_weight + }) + .collect(); + let p3b = + deficit(deliveries).powi(2) * params.mesh_failure_penalty_weight * params.topic_weight; + (p3, p3b) + } + + /// The aggregate topic of the digest a boundary just left. Still scored + /// as current, an honest mesh peer's P3 alone crosses the graylist 53 + /// slots in, before the topic is left; as a previous digest, no topic + /// costs it anything while held or when left. + #[test] + fn a_previous_digest_never_graylists_its_silent_mesh() { + let settings = settings(crate::MESH_N); + let inputs = inputs(1_000_000, true); + + let as_current = settings.digest_topic_params(DIGEST, DigestRole::Current, inputs); + let (p3, _) = silent_mesh_penalties(topic(&as_current, BEACON_AGGREGATE_AND_PROOF)); + let graylisted_at = p3 + .iter() + .position(|&penalty| penalty < GRAYLIST_THRESHOLD) + .map(|tick| tick + 1); + assert_eq!(graylisted_at, Some(53)); + + let as_previous = settings.digest_topic_params(DIGEST, DigestRole::Previous, inputs); + for (topic, params) in &as_previous { + let (p3, p3b) = silent_mesh_penalties(params); + assert!(p3.iter().all(|&penalty| penalty == 0.0), "{topic}"); + assert_eq!(p3b, 0.0, "{topic}"); + } + } + + const FULU: u64 = 1_000; + const BPO: u64 = 2_000; + + /// Fulu, then one blob-parameter-only fork: a single digest boundary. + fn rollover_config() -> Config { + let mut config = Config::mainnet() + .with_fork_epoch(ForkName::Altair, 1) + .with_fork_epoch(ForkName::Bellatrix, 2) + .with_fork_epoch(ForkName::Capella, 3) + .with_fork_epoch(ForkName::Deneb, 4) + .with_fork_epoch(ForkName::Electra, 5) + .with_fork_epoch(ForkName::Fulu, FULU); + config.blob_schedule = SszList::try_from(vec![BlobScheduleEntry { + epoch: BPO, + max_blobs_per_block: 15, + }]) + .expect("within capacity"); + config + } + + /// A beacon `P2PServer` whose swarm commands land in the returned + /// receiver, scoring peers the way `P2P::spawn` sets it up. + async fn capturing_server(config: &Config) -> (P2PServer, UnboundedReceiver) { + let mut server = unconnected_beacon_server(config.clone(), 0).await; + let (swarm_handle, commands) = SwarmHandle::capturing(); + server.swarm_handle = swarm_handle; + // The digest `unconnected_beacon_server` builds its swarm on. + server.peer_scoring = Some(PeerScoring::new(config, [0; 4])); + (server, commands) + } + + /// Plays captured swarm commands into what gossipsub would hold: the + /// installed parameters, plus the topics joined and left. Every scored + /// topic joined must have had its parameters installed by then. + fn replay( + commands: &mut UnboundedReceiver, + installed: &mut HashMap, + scored: &HashSet, + ) -> (HashSet, HashSet) { + let (mut joined, mut left) = (HashSet::new(), HashSet::new()); + while let Ok(command) = commands.try_recv() { + match command { + SwarmCommand::SetTopicScoreParams(topics) => installed.extend( + topics + .into_iter() + .map(|(topic, params)| (topic.hash(), params)), + ), + SwarmCommand::Subscribe(topic) => { + let hash = topic.hash(); + assert!( + !scored.contains(&hash) || installed.contains_key(&hash), + "{topic} joined before it had parameters" + ); + joined.insert(hash); + } + SwarmCommand::Unsubscribe(topic) => { + left.insert(topic.hash()); + } + _ => {} + } + } + (joined, left) + } + + /// A live rollover through [`apply`], with the swarm's commands read in + /// order: each digest is parametrized whole before any of its topics is + /// joined, the digest left behind stops weighing at the switch and is + /// left that way, and the switch restarts the mesh-delivery warmup. + #[tokio::test] + async fn a_rollover_parametrizes_each_digest_before_joining_it() { + let config = rollover_config(); + let (mut server, mut commands) = capturing_server(&config).await; + let schedule = server.wire.beacon().expect("beacon").schedule.clone(); + let (before, after) = (schedule.digest_at(BPO - 1), schedule.digest_at(BPO)); + + let settings = ScoreSettings::new(&config, crate::MESH_N); + let scored_under = |digest| -> HashSet { + settings + .digest_topic_params(digest, DigestRole::Current, ScoreInputs::placeholder()) + .into_iter() + .map(|(topic, _)| topic.hash()) + .collect() + }; + let scored: HashSet = scored_under(before) + .union(&scored_under(after)) + .cloned() + .collect(); + // What is joined and left of them: the attestation subnets only when + // backboned, which this server has none of. + let global_scored_under = |digest| -> HashSet { + [ + BEACON_BLOCK, + BEACON_AGGREGATE_AND_PROOF, + VOLUNTARY_EXIT, + PROPOSER_SLASHING, + ATTESTER_SLASHING, + ] + .into_iter() + .map(|kind| IdentTopic::new(topic_name(digest, kind)).hash()) + .collect() + }; + let mut installed = HashMap::new(); + let params = |installed: &HashMap, digest, kind| { + installed[&IdentTopic::new(topic_name(digest, kind)).hash()].clone() + }; + let dynamic_kinds = [ + BEACON_BLOCK, + BEACON_AGGREGATE_AND_PROOF, + "beacon_attestation_0", + ]; + + apply(&mut server, BPO - 2); + replay(&mut commands, &mut installed, &scored); + // As if the head had kept up for a warmup already. + let scoring = server.peer_scoring.as_mut().unwrap(); + scoring.gate.keeping_up_since = Some(0); + scoring.mesh_deliveries_scored = true; + + // The epoch before: the next digest is joined, every topic of it + // parametrized, none of them scored for deliveries. + apply(&mut server, BPO - 1); + let (joined, _) = replay(&mut commands, &mut installed, &scored); + assert!(global_scored_under(after).is_subset(&joined)); + for kind in dynamic_kinds { + assert!(params(&installed, before, kind).mesh_message_deliveries_weight < 0.0); + let upcoming = params(&installed, after, kind); + assert_eq!(upcoming.mesh_message_deliveries_weight, 0.0, "{kind}"); + assert!(upcoming.topic_weight > 0.0, "{kind}"); + } + + // The switch: the digest left behind weighs nothing, and the new one + // waits out a warmup before its deliveries are scored. + apply(&mut server, BPO); + replay(&mut commands, &mut installed, &scored); + for hash in scored_under(before) { + assert_eq!(installed[&hash].topic_weight, 0.0); + assert_eq!(installed[&hash].mesh_message_deliveries_weight, 0.0); + } + for kind in dynamic_kinds { + let current = params(&installed, after, kind); + assert_eq!(current.mesh_message_deliveries_weight, 0.0, "{kind}"); + } + let scoring = server.peer_scoring.as_ref().unwrap(); + assert!(!scoring.mesh_deliveries_scored); + assert!(scoring.gate.keeping_up_since.is_none()); + + // Warm again: the new digest is scored as the old one was. + server.peer_scoring.as_mut().unwrap().mesh_deliveries_scored = true; + apply(&mut server, BPO + 1); + replay(&mut commands, &mut installed, &scored); + for kind in dynamic_kinds { + assert!(params(&installed, after, kind).mesh_message_deliveries_weight < 0.0); + assert_eq!(params(&installed, before, kind).topic_weight, 0.0); + } + + // Two epochs on, the old digest is left still weighing nothing, so + // leaving it charges its mesh no mesh-failure penalty. + apply(&mut server, BPO + 2); + let (_, left) = replay(&mut commands, &mut installed, &scored); + assert!(global_scored_under(before).is_subset(&left)); + assert!( + scored_under(before) + .iter() + .all(|hash| installed[hash].topic_weight == 0.0) + ); + } + + /// Polls both swarms until `done` holds of `node`'s gossipsub, for at + /// most `limit`, and returns whether it did. + async fn poll_until( + node: &mut BuiltSwarm, + peer: &mut BuiltSwarm, + limit: Duration, + done: impl Fn(&libp2p::gossipsub::Behaviour) -> bool, + ) -> bool { + let deadline = tokio::time::Instant::now() + limit; + while tokio::time::Instant::now() < deadline { + if done(&node.swarm.behaviour().gossipsub) { + return true; + } + tokio::select! { + _ = node.swarm.select_next_some() => {} + _ = peer.swarm.select_next_some() => {} + _ = tokio::time::sleep(Duration::from_millis(100)) => {} + } + } + done(&node.swarm.behaviour().gossipsub) + } + + /// The same rollover on two real swarms. A mesh grafted on the next + /// digest's topics in the epoch before its fork is scored from that GRAFT + /// on, because [`apply`] installs the parameters before it joins. The + /// control is the order this replaces: a topic joined before it had + /// parameters keeps its mesh unscored even after they arrive. + #[tokio::test] + async fn a_mesh_grafted_ahead_of_its_fork_is_scored() { + // One-second slots: gossipsub refreshes its scores once a slot. + let mut config = rollover_config(); + config.slot_duration_ms = 1_000; + let schedule = ForkSchedule::new(&config, Root::ZERO); + let build = |node_key_byte: u8, fork_digest: ForkDigest| { + build_swarm(SwarmConfig { + node_key: vec![node_key_byte; 32], + bootnodes: Vec::new(), + listening_socket: "127.0.0.1:0".parse().expect("valid socket"), + target_peers: crate::discovery::DEFAULT_DISCOVERY_TARGET_PEERS, + agent_version: "ethlambda/test", + wire: WireConfig::Beacon(Box::new(BeaconWireConfig { + fork_digest, + fork: ForkName::Fulu, + config: config.clone(), + genesis_time: config.genesis_time, + genesis_validators_root: Root::ZERO, + custody_columns: Vec::new(), + attestation_subnets: Vec::new(), + })), + }) + .expect("beacon swarm builds") + }; + // `node` is the one scoring. `peer` was built on a digest of its own, + // so the two share only the topics subscribed below. + let mut node = build(1, schedule.digest_at(BPO - 1)); + let mut peer = build(2, [0xEE; 4]); + let peer_id = peer.local_peer_id; + + let node_addr = loop { + if let SwarmEvent::NewListenAddr { address, .. } = node.swarm.select_next_some().await { + break address + .with_p2p(node.local_peer_id) + .expect("failed to add peer ID to multiaddr"); + } + }; + peer.swarm.dial(node_addr).expect("dial is accepted"); + + let control = IdentTopic::new(topic_name([0xAB; 4], BEACON_BLOCK)); + let upcoming = IdentTopic::new(topic_name(schedule.digest_at(BPO), BEACON_BLOCK)); + for topic in [&control, &upcoming] { + let gossipsub = &mut peer.swarm.behaviour_mut().gossipsub; + gossipsub.subscribe(topic).expect("peer subscribes"); + } + let peer_on_both = |gossipsub: &libp2p::gossipsub::Behaviour| { + gossipsub.all_peers().any(|(id, topics)| { + *id == peer_id + && topics.contains(&&control.hash()) + && topics.contains(&&upcoming.hash()) + }) + }; + let announced = + poll_until(&mut node, &mut peer, Duration::from_secs(10), peer_on_both).await; + assert!(announced, "the peer's subscriptions reach the node"); + let in_mesh = |gossipsub: &libp2p::gossipsub::Behaviour, topic: &IdentTopic| { + gossipsub.mesh_peers(&topic.hash()).any(|id| *id == peer_id) + }; + + // The control: joined first, so the join grafts the peer before the + // topic has parameters. + let settings = ScoreSettings::new(&config, crate::MESH_N); + let control_params = settings + .digest_topic_params([0xAB; 4], DigestRole::Upcoming, ScoreInputs::placeholder()) + .into_iter() + .find(|(topic, _)| topic.hash() == control.hash()) + .expect("the block topic is scored") + .1; + let gossipsub = &mut node.swarm.behaviour_mut().gossipsub; + gossipsub.subscribe(&control).expect("node subscribes"); + gossipsub + .set_topic_params(control.clone(), control_params) + .expect("the beacon swarm scores peers"); + assert!(in_mesh(&node.swarm.behaviour().gossipsub, &control)); + // Long enough for two score refreshes. + poll_until(&mut node, &mut peer, Duration::from_millis(2_500), |_| { + false + }) + .await; + let gossipsub = &node.swarm.behaviour().gossipsub; + assert_eq!(gossipsub.peer_score(&peer_id), Some(0.0)); + + // The rollover's own order, at the epoch before the fork. + let (mut server, mut commands) = capturing_server(&config).await; + apply(&mut server, BPO - 1); + while let Ok(command) = commands.try_recv() { + execute_command(&mut node.swarm, command); + } + assert!(in_mesh(&node.swarm.behaviour().gossipsub, &upcoming)); + let scored = poll_until(&mut node, &mut peer, Duration::from_secs(10), |gossipsub| { + gossipsub + .peer_score(&peer_id) + .is_some_and(|score| score > 0.0) + }) + .await; + assert!( + scored, + "the mesh grafted ahead of the fork earns time in mesh" + ); + } + + /// The built beacon swarm scores peers and already has parameters for a + /// subnet it does not subscribe to; a lean swarm scores nothing. + #[tokio::test] + async fn only_the_beacon_swarm_scores_peers() { + let swarm_config = |wire| SwarmConfig { + node_key: vec![3u8; 32], + bootnodes: Vec::new(), + listening_socket: "127.0.0.1:0".parse().expect("valid socket"), + target_peers: crate::discovery::DEFAULT_DISCOVERY_TARGET_PEERS, + agent_version: "ethlambda/test", + wire, + }; + + let beacon = build_swarm(swarm_config(WireConfig::Beacon(Box::new( + BeaconWireConfig { + fork_digest: DIGEST, + fork: ForkName::Fulu, + config: Config::mainnet(), + genesis_time: 1_606_824_023, + genesis_validators_root: Root::ZERO, + custody_columns: vec![3], + attestation_subnets: vec![5], + }, + )))) + .expect("beacon swarm builds"); + let gossipsub = &beacon.swarm.behaviour().gossipsub; + assert!(gossipsub.peer_score(&beacon.local_peer_id).is_some()); + let unsubscribed_subnet = IdentTopic::new(attestation_topic_name(DIGEST, 40)); + assert!(gossipsub.get_topic_params(&unsubscribed_subnet).is_some()); + let column = IdentTopic::new(crate::beacon::topics::data_column_topic_name(DIGEST, 3)); + assert!( + gossipsub.get_topic_params(&column).is_none(), + "columns are not scored" + ); + + let lean = build_swarm(swarm_config(WireConfig::Lean(LeanWireConfig { + validator_ids: Vec::new(), + attestation_committee_count: 1, + subscription_subnets: HashSet::new(), + milliseconds_per_slot: 4_000, + }))) + .expect("lean swarm builds"); + assert!( + lean.swarm + .behaviour() + .gossipsub + .peer_score(&lean.local_peer_id) + .is_none() + ); + } + + #[test] + fn the_score_bands_are_split_at_the_thresholds() { + assert_eq!(score_band(10.0), "non_negative"); + assert_eq!(score_band(0.0), "non_negative"); + assert_eq!(score_band(-1.0), "negative"); + assert_eq!(score_band(GOSSIP_THRESHOLD), "negative"); + assert_eq!(score_band(GOSSIP_THRESHOLD - 1.0), "below_gossip"); + assert_eq!(score_band(PUBLISH_THRESHOLD - 1.0), "below_publish"); + assert_eq!(score_band(GRAYLIST_THRESHOLD - 1.0), "below_graylist"); + for score in [10.0, -1.0, -5000.0, -9000.0, -20000.0] { + assert!(SCORE_BANDS.contains(&score_band(score))); + } + } +} diff --git a/crates/net/p2p/src/beacon/swarm.rs b/crates/net/p2p/src/beacon/swarm.rs index 0acb9cfd0..bb90cdbdf 100644 --- a/crates/net/p2p/src/beacon/swarm.rs +++ b/crates/net/p2p/src/beacon/swarm.rs @@ -1,10 +1,11 @@ //! The beacon half of the swarm configuration. //! -//! Five things differ from lean at the swarm level: the topic set, the protocol -//! set, the `seen_ttl`, the identify protocol version and the connection limits. -//! [`crate::build_swarm`] resolves all five from the variant it is handed, and -//! every one of them that is a beacon *value* rather than a lean one lives here, -//! so tuning the mainnet numbers never means editing the crate root. +//! Six things differ from lean at the swarm level: the topic set, the protocol +//! set, the `seen_ttl`, the identify protocol version, the connection limits and +//! peer scoring. [`crate::build_swarm`] resolves all six from the variant it is +//! handed, and every one of them that is a beacon *value* rather than a lean one +//! lives here or, for scoring, in [`crate::beacon::scoring`], so tuning the +//! mainnet numbers never means editing the crate root. use std::time::Duration; diff --git a/crates/net/p2p/src/beacon/transition.rs b/crates/net/p2p/src/beacon/transition.rs index 575a04712..9bc6f378f 100644 --- a/crates/net/p2p/src/beacon/transition.rs +++ b/crates/net/p2p/src/beacon/transition.rs @@ -107,13 +107,16 @@ pub(crate) fn advance(server: &mut P2PServer) -> Option { /// Move the node to the state the schedule prescribes at `epoch`. /// -/// 1. Join the topics of every digest the window now covers, including the +/// 1. Hand the swarm the peer-scoring parameters of every digest the window +/// now covers, each by its role (beacon scoring only; see +/// [`super::scoring::PeerScoring::hold`]). +/// 2. Join the topics of every digest the window now covers, including the /// aggregator-subnet topics already held for the current slot. -/// 2. Leave the topics of every digest the window no longer covers. -/// 3. If the current digest changed, switch what this node publishes and +/// 3. Leave the topics of every digest the window no longer covers. +/// 4. If the current digest changed, switch what this node publishes and /// advertises: digest, fork, metadata sequence number, metric, and the /// `eth2` entry. -/// 4. Point the admission filter at the new entry, admitting every held digest. +/// 5. Point the admission filter at the new entry, admitting every held digest. pub(crate) fn apply(server: &mut P2PServer, epoch: Epoch) -> Changes { let mut changes = Changes::default(); let Wire::Beacon(wire) = &mut server.wire else { @@ -132,6 +135,19 @@ pub(crate) fn apply(server: &mut P2PServer, epoch: Epoch) -> Changes { } let column_subnets = topics::column_subnets(&wire.custody_columns); + // Before any join: gossipsub tracks a mesh peer on a topic only if the + // topic had parameters when the peer was grafted, and joining grafts at + // once. + if let Some(scoring) = &mut server.peer_scoring { + let digests = held.iter().map(|entry| entry.digest).collect(); + scoring.hold( + digests, + current.digest, + wire.wall_slot(), + &server.swarm_handle, + ); + } + let mut existing = std::mem::take(&mut wire.window_topics); existing.push(wire.topics.clone()); diff --git a/crates/net/p2p/src/gossipsub/handler.rs b/crates/net/p2p/src/gossipsub/handler.rs index fc87304c7..d6b90bce0 100644 --- a/crates/net/p2p/src/gossipsub/handler.rs +++ b/crates/net/p2p/src/gossipsub/handler.rs @@ -1143,7 +1143,7 @@ pub async fn publish_payload_attestation_message( } /// The beacon wall-clock slot, from the wire's genesis and slot duration. -fn beacon_wall_slot(wire: &BeaconWire) -> u64 { +pub(crate) fn beacon_wall_slot(wire: &BeaconWire) -> u64 { wire.wall_slot() } diff --git a/crates/net/p2p/src/gossipsub/mod.rs b/crates/net/p2p/src/gossipsub/mod.rs index 6cf52b317..7ba46286c 100644 --- a/crates/net/p2p/src/gossipsub/mod.rs +++ b/crates/net/p2p/src/gossipsub/mod.rs @@ -3,6 +3,7 @@ mod handler; mod messages; pub use encoding::{compress_message, decompress_message}; +pub(crate) use handler::beacon_wall_slot; pub(crate) use handler::operation_kind; pub use handler::{ handle_gossip_message, join_aggregator_subnets, leave_expired_aggregator_subnets, diff --git a/crates/net/p2p/src/lib.rs b/crates/net/p2p/src/lib.rs index bc37d9dac..dc2f1f1fa 100644 --- a/crates/net/p2p/src/lib.rs +++ b/crates/net/p2p/src/lib.rs @@ -657,9 +657,9 @@ pub struct SwarmConfig { /// Ignored on lean, which runs [`unlimited_connections`]. pub target_peers: usize, /// Which network's wire to build. Decides the gossip topics, the req/resp - /// protocol set, the gossipsub `seen_ttl`, the identify protocol version and - /// the connection limits, and so decides the [`Wire`] the built swarm - /// carries. + /// protocol set, the gossipsub `seen_ttl`, the identify protocol version, + /// the connection limits and whether peers are scored, and so decides the + /// [`Wire`] the built swarm carries. pub wire: WireConfig, /// The identify `agentVersion`, which crawlers read to name a peer's /// client. Left unset, rust-libp2p reports its own crate version instead. @@ -812,6 +812,11 @@ pub enum SwarmBuildError { Subscription(#[from] libp2p::gossipsub::SubscriptionError), } +/// Gossipsub's target mesh size, the spec's `D`. Named because beacon peer +/// scoring sizes its first-delivery caps by it; see +/// [`beacon::scoring::ScoreSettings`]. +pub(crate) const MESH_N: usize = 8; + /// The gossipsub parameters both wires share. /// /// `mesh_n` 8, low 6, high 12, the 700ms heartbeat, and the 6/3 history already @@ -830,7 +835,7 @@ pub(crate) fn gossipsub_config( let mut builder = libp2p::gossipsub::ConfigBuilder::default(); builder // d - .mesh_n(8) + .mesh_n(MESH_N) // d_low .mesh_n_low(6) // d_high @@ -858,9 +863,10 @@ pub(crate) fn gossipsub_config( /// Build and configure the libp2p swarm, dial bootnodes, subscribe to topics. /// /// One builder for both networks. Four things differ at the behaviour level and -/// are resolved in the first match below; the topic set differs and is resolved -/// in the last one. Everything in between, the transport, the QUIC and TCP -/// listeners and the static bootnode dialing, is the same on either wire. +/// are resolved in the first match below, peer scoring (beacon only) just after +/// it; the topic set differs and is resolved in the last one. Everything in +/// between, the transport, the QUIC and TCP listeners and the static bootnode +/// dialing, is the same on either wire. pub fn build_swarm(config: SwarmConfig) -> Result { let SwarmConfig { node_key, @@ -895,11 +901,27 @@ pub fn build_swarm(config: SwarmConfig) -> Result { }; let validate_messages = matches!(wire, WireConfig::Beacon(_)); - let gossipsub = libp2p::gossipsub::Behaviour::new( + let mut gossipsub = libp2p::gossipsub::Behaviour::new( MessageAuthenticity::Anonymous, gossipsub_config(seen_ttl, validate_messages), ) .expect("failed to initiate behaviour"); + // Beacon only: the parameters are sized from the beacon chain's topics + // and message rates, and lean has no set of its own. Without a verdict + // on each message (lean's are auto-accepted) the invalid-message term + // would have nothing to count either. Built for a placeholder validator + // count; `P2PServer`'s first scoring refresh, at startup, replaces the + // topic parameters with the head's. + if let WireConfig::Beacon(beacon) = &wire { + let settings = beacon::scoring::ScoreSettings::new(&beacon.config, MESH_N); + let params = settings.peer_score_params( + beacon.fork_digest, + beacon::scoring::ScoreInputs::placeholder(), + ); + gossipsub + .with_peer_score(params, beacon::scoring::thresholds()) + .expect("valid peer score parameters"); + } let req_resp = ReqResp::new(codec, &wire); @@ -921,8 +943,6 @@ pub fn build_swarm(config: SwarmConfig) -> Result { req_resp, }; - // TODO: set peer scoring params - let mut swarm = libp2p::SwarmBuilder::with_existing_identity(identity) .with_tokio() .with_tcp( @@ -1165,6 +1185,10 @@ impl P2P { .wire .beacon() .map_or(0, |beacon| beacon.attestation_subnets.len()); + let peer_scoring = built + .wire + .beacon() + .map(|beacon| beacon::scoring::PeerScoring::new(&beacon.config, beacon.fork_digest)); let server = P2PServer { swarm_handle, @@ -1217,9 +1241,20 @@ impl P2P { )), sync_committee_pool, aggregator_subnets: HashMap::new(), + peer_scoring, }; + let scores_peers = server.peer_scoring.is_some(); let discovery_enabled = server.discovery.is_some(); let handle = server.start(); + // At once rather than a slot in: the swarm starts on placeholder + // topic scores, and the head's are already known. + if scores_peers { + send_after( + Duration::ZERO, + handle.context(), + p2p_protocol::RefreshPeerScoring, + ); + } send_after( AGGREGATOR_SUBNET_SWEEP_INTERVAL, handle.context(), @@ -1394,6 +1429,10 @@ pub struct P2PServer { /// advertised in `attnets`, and left once the slot has passed. The /// backbone subnets are separate and never left. pub(crate) aggregator_subnets: HashMap, + + /// Gossipsub scoring's inputs and gate. `None` on lean, which runs + /// without peer scoring. See [`beacon::scoring`]. + pub(crate) peer_scoring: Option, } impl P2PServer { @@ -1487,6 +1526,8 @@ pub(crate) trait P2PProtocol: Send + Sync { /// then by its own timer for the next join, switch or leave. #[allow(dead_code)] // invoked via send_after, not called directly fn advance_fork_schedule(&self) -> Result<(), ActorError>; + #[allow(dead_code)] // invoked via send_after, not called directly + fn refresh_peer_scoring(&self) -> Result<(), ActorError>; } #[actor(protocol = P2PProtocol)] @@ -1645,6 +1686,21 @@ impl P2PServer { send_after(interval, ctx.clone(), p2p_protocol::DiscoverPeers); } + /// Rebuild the gossipsub topic scores from the head, once a slot. Beacon + /// only: never scheduled on lean. See [`beacon::scoring::refresh`]. + #[send_handler] + async fn handle_refresh_peer_scoring( + &mut self, + _msg: p2p_protocol::RefreshPeerScoring, + ctx: &Context, + ) { + if let Some(wire) = self.wire.beacon() { + let slot = Duration::from_millis(wire.config.slot_duration_ms); + send_after(slot, ctx.clone(), p2p_protocol::RefreshPeerScoring); + } + beacon::scoring::refresh(self); + } + /// The deadline of a range batch held back for custody. Scheduled once, /// when the batch starts waiting; see [`RANGE_BATCH_CUSTODY_WAIT`]. #[send_handler] @@ -3013,6 +3069,7 @@ pub(crate) mod test_support { )), sync_committee_pool: Default::default(), aggregator_subnets: HashMap::new(), + peer_scoring: None, } } diff --git a/crates/net/p2p/src/metrics.rs b/crates/net/p2p/src/metrics.rs index a9779c211..643300967 100644 --- a/crates/net/p2p/src/metrics.rs +++ b/crates/net/p2p/src/metrics.rs @@ -591,6 +591,51 @@ pub fn set_swarm_established_connections(inbound: u32, outbound: u32) { .set(i64::from(outbound)); } +/// Set how many gossipsub peers fall in each score band. `bands` names every +/// band, empty ones as zero, so a band that empties does not keep its last +/// count. See `beacon::scoring::score_band`. +pub fn set_gossipsub_peers_by_score(bands: &HashMap<&'static str, i64>) { + static LEAN_GOSSIPSUB_PEERS_BY_SCORE: LazyLock = LazyLock::new(|| { + register_int_gauge_vec!( + "lean_gossipsub_peers_by_score", + "Gossipsub peers by score band: non_negative, negative, below_gossip, below_publish, below_graylist", + &["band"] + ) + .unwrap() + }); + for (band, count) in bands { + LEAN_GOSSIPSUB_PEERS_BY_SCORE + .with_label_values(&[band]) + .set(*count); + } +} + +/// Count a peer disconnected for a gossipsub score below the graylist. +pub fn inc_gossipsub_score_disconnects() { + static LEAN_GOSSIPSUB_SCORE_DISCONNECTS_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter!( + "lean_gossipsub_score_disconnects_total", + "Peers disconnected for a gossipsub score below the graylist threshold" + ) + .unwrap() + }); + LEAN_GOSSIPSUB_SCORE_DISCONNECTS_TOTAL.inc(); +} + +/// Whether gossipsub's mesh-delivery term (P3) is being scored: 0 while the +/// head lags or is still warming up after catching up. See +/// `beacon::scoring::MeshDeliveryGate`. +pub fn set_gossipsub_mesh_delivery_scoring(scored: bool) { + static LEAN_GOSSIPSUB_MESH_DELIVERY_SCORING: LazyLock = LazyLock::new(|| { + register_int_gauge!( + "lean_gossipsub_mesh_delivery_scoring", + "1 while gossipsub scores mesh message deliveries, 0 while the head lags or is warming up" + ) + .unwrap() + }); + LEAN_GOSSIPSUB_MESH_DELIVERY_SCORING.set(i64::from(scored)); +} + /// Set how many connected peers are known to custody `column`. /// /// A peer counts only once it has answered `metadata/3` or arrived with a diff --git a/crates/net/p2p/src/req_resp/handlers.rs b/crates/net/p2p/src/req_resp/handlers.rs index 9a86f8889..5801a84f2 100644 --- a/crates/net/p2p/src/req_resp/handlers.rs +++ b/crates/net/p2p/src/req_resp/handlers.rs @@ -2840,6 +2840,7 @@ pub(crate) mod tests { )), sync_committee_pool: Default::default(), aggregator_subnets: HashMap::new(), + peer_scoring: None, } } diff --git a/crates/net/p2p/src/swarm_adapter.rs b/crates/net/p2p/src/swarm_adapter.rs index 1303c52d7..245827325 100644 --- a/crates/net/p2p/src/swarm_adapter.rs +++ b/crates/net/p2p/src/swarm_adapter.rs @@ -4,18 +4,20 @@ use std::time::Duration; use libp2p::{ PeerId, futures::StreamExt, + gossipsub::{IdentTopic, TopicScoreParams}, request_response, swarm::{SwarmEvent, dial_opts::DialOpts}, }; use tokio::{sync::mpsc, time::MissedTickBehavior}; -use tracing::{debug, error, warn}; +use tracing::{debug, error, info, warn}; use crate::{ - Behaviour, BehaviourEvent, ReqRespProtocol, ReqRespRequestId, metrics, req_resp::Request, - req_resp::Response, + Behaviour, BehaviourEvent, ReqRespProtocol, ReqRespRequestId, beacon::scoring, metrics, + req_resp::Request, req_resp::Response, }; -/// Interval between gossipsub mesh peer metric refreshes. +/// Interval between gossipsub mesh peer metric refreshes, and between the +/// checks that disconnect peers scored below the graylist. const MESH_METRIC_REFRESH_INTERVAL: Duration = Duration::from_secs(10); pub enum SwarmCommand { @@ -49,6 +51,10 @@ pub enum SwarmCommand { channel: request_response::ResponseChannel, response: Response, }, + /// Replace these topics' score parameters (beacon only; see + /// [`scoring::refresh`] and [`crate::beacon::transition::apply`], which + /// sends them ahead of the topics it joins). + SetTopicScoreParams(Vec<(IdentTopic, TopicScoreParams)>), /// A verdict for a gossip message gossipsub is holding (beacon only). ReportValidation { message_id: libp2p::gossipsub::MessageId, @@ -204,6 +210,13 @@ impl SwarmHandle { .inspect_err(|_| debug!("Swarm adapter closed, cannot send response")); } + pub fn set_topic_score_params(&self, topics: Vec<(IdentTopic, TopicScoreParams)>) { + let _ = self + .cmd_tx + .send(SwarmCommand::SetTopicScoreParams(topics)) + .inspect_err(|_| debug!("Swarm adapter closed, cannot set topic score parameters")); + } + pub fn report_validation( &self, message_id: libp2p::gossipsub::MessageId, @@ -221,6 +234,14 @@ impl SwarmHandle { }) .inspect_err(|_| debug!("Swarm adapter closed, cannot report a gossip verdict")); } + + /// A handle whose commands land in the returned receiver instead of a + /// swarm, so a test can read what was sent, and in what order. + #[cfg(test)] + pub(crate) fn capturing() -> (Self, mpsc::UnboundedReceiver) { + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); + (Self { cmd_tx }, cmd_rx) + } } pub fn start_swarm_adapter( @@ -277,13 +298,51 @@ async fn swarm_loop( counters.num_established_incoming(), counters.num_established_outgoing(), ); + disconnect_graylisted_peers(&mut swarm); } } } error!("Swarm adapter loop exited — P2P networking is no longer functional"); } -fn execute_command(swarm: &mut libp2p::Swarm, cmd: SwarmCommand) { +/// Disconnect every peer gossipsub scores below the graylist, and publish how +/// the rest are spread across the score bands. +/// +/// Gossipsub on its own only ignores a graylisted peer's RPCs, so the peer +/// would keep a connection slot it is no use in. There is no ban list, so the +/// peer may reconnect, but its score is retained for `retain_score` after it +/// leaves: it comes back graylisted and is dropped again on the next pass. +/// +/// Does nothing on lean, which runs without scoring. +fn disconnect_graylisted_peers(swarm: &mut libp2p::Swarm) { + let gossipsub = &swarm.behaviour().gossipsub; + // `peer_score` is `Some` for any peer id, this node's own included, + // exactly when scoring is on. + if gossipsub.peer_score(swarm.local_peer_id()).is_none() { + return; + } + let mut bands: HashMap<&'static str, i64> = + scoring::SCORE_BANDS.iter().map(|&band| (band, 0)).collect(); + let mut graylisted = Vec::new(); + for (peer_id, _topics) in gossipsub.all_peers() { + let Some(score) = gossipsub.peer_score(peer_id) else { + continue; + }; + *bands.entry(scoring::score_band(score)).or_default() += 1; + if score < scoring::GRAYLIST_THRESHOLD { + graylisted.push((*peer_id, score)); + } + } + metrics::set_gossipsub_peers_by_score(&bands); + for (peer_id, score) in graylisted { + if swarm.disconnect_peer_id(peer_id).is_ok() { + metrics::inc_gossipsub_score_disconnects(); + info!(%peer_id, score, "Disconnecting peer scored below the gossipsub graylist"); + } + } +} + +pub(crate) fn execute_command(swarm: &mut libp2p::Swarm, cmd: SwarmCommand) { match cmd { SwarmCommand::Publish { topic, data } => { swarm @@ -392,6 +451,14 @@ fn execute_command(swarm: &mut libp2p::Swarm, cmd: SwarmCommand) { .send_response(channel, response) .inspect_err(|response| debug!(%response, "Swarm adapter: send_response failed")); } + SwarmCommand::SetTopicScoreParams(topics) => { + let gossipsub = &mut swarm.behaviour_mut().gossipsub; + for (topic, params) in topics { + let _ = gossipsub + .set_topic_params(topic, params) + .inspect_err(|err| debug!(%err, "Swarm adapter: topic score update failed")); + } + } SwarmCommand::ReportValidation { message_id, propagation_source, diff --git a/crates/net/rpc/src/beacon/ptc.rs b/crates/net/rpc/src/beacon/ptc.rs index c83e47fd1..be80bf330 100644 --- a/crates/net/rpc/src/beacon/ptc.rs +++ b/crates/net/rpc/src/beacon/ptc.rs @@ -570,8 +570,8 @@ mod tests { }; // Distinct roots per slot, so a `dependent_root` read from the wrong // slot shows up. - for (slot, root) in inner.block_roots.iter_mut().enumerate() { - *root = H256::repeat_byte(slot as u8 + 1); + for slot in 0..inner.block_roots.len() { + inner.block_roots[slot] = H256::repeat_byte(slot as u8 + 1); } for index in 0..preset::PTC_WINDOW_LENGTH { inner.ptc_window[index] = vec![index as u64; preset::PTC_SIZE] diff --git a/crates/net/rpc/src/beacon/validator.rs b/crates/net/rpc/src/beacon/validator.rs index 046abb03e..ee2f0bcde 100644 --- a/crates/net/rpc/src/beacon/validator.rs +++ b/crates/net/rpc/src/beacon/validator.rs @@ -892,8 +892,8 @@ mod tests { unreachable!("built as fulu") }; fulu.proposer_lookahead = lookahead.try_into().unwrap(); - for (slot, root) in fulu.block_roots.iter_mut().enumerate() { - *root = H256::repeat_byte(slot as u8 + 1); + for slot in 0..fulu.block_roots.len() { + fulu.block_roots[slot] = H256::repeat_byte(slot as u8 + 1); } state } diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index d01517ecf..9173f0ac4 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -302,7 +302,6 @@ pub(crate) mod test_utils { state::{JustificationValidators, JustifiedSlots, State, StateConfig}, }; use libssz::SszEncode; - use libssz_types::SszVector; /// Build the API router the way tests do, with placeholder client version /// and peer ID. Tests that assert on those identity values (e.g. the @@ -411,9 +410,11 @@ pub(crate) mod test_utils { state_root: H256::ZERO, body_root: H256::ZERO, }, - block_roots: SszVector::try_from(vec![H256::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT]) + block_roots: vec![H256::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT] + .try_into() .expect("exactly N elements by construction"), - state_roots: SszVector::try_from(vec![H256::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT]) + state_roots: vec![H256::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT] + .try_into() .expect("exactly N elements by construction"), historical_roots: Default::default(), eth1_data: Default::default(), @@ -421,12 +422,11 @@ pub(crate) mod test_utils { eth1_deposit_index: 0, validators: Default::default(), balances: Default::default(), - randao_mixes: SszVector::try_from(vec![ - H256::ZERO; - preset::EPOCHS_PER_HISTORICAL_VECTOR - ]) - .expect("exactly N elements by construction"), - slashings: SszVector::try_from(vec![0u64; preset::EPOCHS_PER_SLASHINGS_VECTOR]) + randao_mixes: vec![H256::ZERO; preset::EPOCHS_PER_HISTORICAL_VECTOR] + .try_into() + .expect("exactly N elements by construction"), + slashings: vec![0u64; preset::EPOCHS_PER_SLASHINGS_VECTOR] + .try_into() .expect("exactly N elements by construction"), previous_epoch_attestations: Default::default(), current_epoch_attestations: Default::default(), diff --git a/crates/storage/src/api/tables.rs b/crates/storage/src/api/tables.rs index 86ed0d1a4..f093a42ac 100644 --- a/crates/storage/src/api/tables.rs +++ b/crates/storage/src/api/tables.rs @@ -15,16 +15,25 @@ pub enum Table { BlockProof, /// Canonical block index: slot -> block root BlockRoots, - /// State storage: H256 -> State + /// State storage: slot || root -> State /// /// Holds full-state snapshots only: the bootstrap anchor plus one anchor - /// every `SNAPSHOT_ANCHOR_INTERVAL` slots. Never pruned. Non-anchor states live in `StateDiffs` and - /// are reconstructed on demand (memoized by an in-memory cache). + /// every `SNAPSHOT_ANCHOR_INTERVAL` slots. Non-anchor states live in + /// `StateDiffs` and are reconstructed on demand (memoized by an in-memory + /// cache). + /// + /// The key is the slot (big-endian) of the block the state belongs to, + /// then that block's root. The slot comes first so that everything below + /// a finalized slot is one contiguous key range that + /// `Store::prune_finalized_states` drops with a range delete; a bare root + /// key would scatter finalized states across the keyspace. Readers holding + /// only a root recover the slot from its `BlockHeaders` row. States, - /// State diffs: H256 -> StateDiff + /// State diffs: slot || root -> StateDiff /// - /// Parent-linked diff written for every non-genesis state. Never pruned, so - /// it preserves full state history. See `get_state` for reconstruction. + /// Parent-linked diff written for every non-anchor state, keyed like + /// `States`. Pruned below the finalized snapshot interval together with + /// `States`. See `get_state` for reconstruction. StateDiffs, /// Metadata: string keys -> various scalar values Metadata, diff --git a/crates/storage/src/backend/rocksdb.rs b/crates/storage/src/backend/rocksdb.rs index cccdb39af..4e51943c6 100644 --- a/crates/storage/src/backend/rocksdb.rs +++ b/crates/storage/src/backend/rocksdb.rs @@ -49,10 +49,15 @@ fn enable_blob_files(cf_opts: &mut Options) { // compression, so moving the values out would otherwise grow the tables // on disk. The values are raw SSZ. cf_opts.set_blob_compression_type(DBCompressionType::Lz4); - // No blob garbage collection: nothing deletes or overwrites a state, so it - // would only relocate live blobs during compaction. Revisit if states are - // ever pruned. No blob cache either: the store caches decoded states - // itself, and a snapshot-sized entry would evict the whole block cache. + // No blob garbage collection, even though finalized states are pruned. + // RocksDB drops a blob file once no SST links to it and every blob in it + // is garbage (`AddBlobFileIfNeeded` in `db/version_builder.cc`), and + // compaction marks blobs garbage as it drops the keys a range tombstone + // covers. Prunes delete whole slot ranges, and a blob file holds the + // values of one flush, so the files written for pruned slots empty out and + // are deleted without GC; GC would only relocate live blobs. No blob cache + // either: the store caches decoded states itself, and a snapshot-sized + // entry would evict the whole block cache. } /// RocksDB storage backend. diff --git a/crates/storage/src/beacon_state_delta.rs b/crates/storage/src/beacon_state_delta.rs index 11e7ec37a..44233b425 100644 --- a/crates/storage/src/beacon_state_delta.rs +++ b/crates/storage/src/beacon_state_delta.rs @@ -113,7 +113,7 @@ pub(crate) fn unframe(bytes: &[u8]) -> (H256, u64, u64, &[u8]) { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use proptest::prelude::*; @@ -289,7 +289,14 @@ mod tests { /// [`advance_across_epoch_boundary`]) are counted too, which is why every /// test that calls this is `#[ignore]`d. fn mainnet_scale_electra_state() -> electra::BeaconState { - let validators: Vec = (0..VALIDATOR_COUNT) + electra_state_with_validators(VALIDATOR_COUNT) + } + + /// [`mainnet_scale_electra_state`] with `count` validators, for tests that + /// need the electra shape (every tree-backed field populated) without the + /// mainnet cost. + pub(crate) fn electra_state_with_validators(count: usize) -> electra::BeaconState { + let validators: Vec = (0..count) .map(|_| Validator { effective_balance: preset::MIN_ACTIVATION_BALANCE, activation_eligibility_epoch: 0, @@ -320,33 +327,33 @@ mod tests { eth1_deposit_index: 0, validators: validators .try_into() - .expect("VALIDATOR_COUNT is far below VALIDATOR_REGISTRY_LIMIT"), - balances: vec![preset::MIN_ACTIVATION_BALANCE; VALIDATOR_COUNT] + .expect("`count` is far below VALIDATOR_REGISTRY_LIMIT"), + balances: vec![preset::MIN_ACTIVATION_BALANCE; count] .try_into() - .expect("VALIDATOR_COUNT is far below VALIDATOR_REGISTRY_LIMIT"), + .expect("`count` is far below VALIDATOR_REGISTRY_LIMIT"), randao_mixes: vec![H256::ZERO; preset::EPOCHS_PER_HISTORICAL_VECTOR] .try_into() .expect("the vector is built at its exact length"), slashings: vec![0; preset::EPOCHS_PER_SLASHINGS_VECTOR] .try_into() .expect("the vector is built at its exact length"), - previous_epoch_participation: vec![0u8; VALIDATOR_COUNT] + previous_epoch_participation: vec![0u8; count] .try_into() - .expect("VALIDATOR_COUNT is far below VALIDATOR_REGISTRY_LIMIT"), + .expect("`count` is far below VALIDATOR_REGISTRY_LIMIT"), // Uniform and non-zero, standing in for "everyone was timely last // epoch": what makes advance_across_epoch_boundary's shift of this // into `previous_epoch_participation` a real change rather than a // zero-to-zero no-op. - current_epoch_participation: vec![0b0000_0111u8; VALIDATOR_COUNT] + current_epoch_participation: vec![0b0000_0111u8; count] .try_into() - .expect("VALIDATOR_COUNT is far below VALIDATOR_REGISTRY_LIMIT"), + .expect("`count` is far below VALIDATOR_REGISTRY_LIMIT"), justification_bits: Default::default(), previous_justified_checkpoint: Default::default(), current_justified_checkpoint: Default::default(), finalized_checkpoint: Default::default(), - inactivity_scores: vec![0u64; VALIDATOR_COUNT] + inactivity_scores: vec![0u64; count] .try_into() - .expect("VALIDATOR_COUNT is far below VALIDATOR_REGISTRY_LIMIT"), + .expect("`count` is far below VALIDATOR_REGISTRY_LIMIT"), current_sync_committee: empty_sync_committee(), next_sync_committee: empty_sync_committee(), latest_execution_payload_header: execution_payload_header_for_slot(0), @@ -419,8 +426,8 @@ mod tests { for index in 0..state.balances.len() { state.balances[index] = balance_for_index(index); } - for (index, score) in state.inactivity_scores.iter_mut().enumerate() { - *score = inactivity_score_for_index(index); + for index in 0..state.inactivity_scores.len() { + state.inactivity_scores[index] = inactivity_score_for_index(index); } // Most validators' effective balance moves; hysteresis means a diff --git a/crates/storage/src/committee_cache.rs b/crates/storage/src/committee_cache.rs index 763ee235b..35c079d9c 100644 --- a/crates/storage/src/committee_cache.rs +++ b/crates/storage/src/committee_cache.rs @@ -83,6 +83,10 @@ const COMMITTEE_CACHE_CAPACITY: usize = 8; /// pins: its previous, current, and next epochs'. const HEAD_SHUFFLINGS: usize = 3; +/// Where the head's current epoch sits among [`CommitteeCache::pin_head`]'s +/// keys, which name its previous, current and next epochs in that order. +const HEAD_CURRENT_EPOCH: usize = 1; + const _: () = assert!( COMMITTEE_CACHE_CAPACITY > HEAD_SHUFFLINGS, "the cache must hold at least one shuffling the head does not pin" @@ -267,6 +271,22 @@ impl CommitteeCache { pub fn pin_head(&self, head_root: Root, keys: [Option; HEAD_SHUFFLINGS]) { self.state.lock().unwrap().head = Some((head_root, keys)); } + + /// The pinned head's current-epoch committees, if that shuffling has been + /// built. + /// + /// Never builds one: a caller that only wants a figure off the head (the + /// active validator count gossipsub scoring sizes its expected message + /// rates by) has no state to build from, and waiting for the chain actor + /// to need that shuffling is cheaper than deriving it here. `None` until + /// a head has been pinned and its current epoch's shuffling filled in. + pub fn head_current_committees(&self) -> Option> { + let state = self.state.lock().unwrap(); + let (_, keys) = state.head.as_ref()?; + let current = keys[HEAD_CURRENT_EPOCH]?; + let (_, slot) = state.entries.iter().find(|(key, _)| *key == current)?; + slot.get().cloned() + } } #[cfg(test)] @@ -380,6 +400,28 @@ mod tests { assert!(cache.state.lock().unwrap().entries.len() <= COMMITTEE_CACHE_CAPACITY); } + /// `head_current_committees` answers with the pinned head's + /// current-epoch entry, the middle of the three it pins, and only once + /// that entry has been built: it is a read, never a derivation. + #[test] + fn the_head_current_committees_are_the_middle_pin_once_built() { + let cache = CommitteeCache::default(); + assert!(cache.head_current_committees().is_none(), "nothing pinned"); + + let pinned_keys = [Some(key(1, 1)), Some(key(2, 2)), Some(key(3, 3))]; + cache.pin_head(Root::repeat_byte(0xAA), pinned_keys); + assert!( + cache.head_current_committees().is_none(), + "pinned but not built" + ); + + cache.get_or_init(key(2, 2), || EpochCommittees::new(2, vec![7, 8, 9], 1)); + let current = cache + .head_current_committees() + .expect("the current epoch's shuffling is built"); + assert_eq!(current.active_validator_count(), 3); + } + /// Concurrent misses on the same key must run `build` exactly once: the /// rest wait on the first call's result rather than each deriving their /// own. This is the property the module documentation calls load-bearing diff --git a/crates/storage/src/state_writer.rs b/crates/storage/src/state_writer.rs index 5e6cc670c..723802b69 100644 --- a/crates/storage/src/state_writer.rs +++ b/crates/storage/src/state_writer.rs @@ -28,6 +28,11 @@ //! [`StateWriterHandle`]'s doc), which is what makes "dropped" and //! "settled" the same event for every test in this crate, and what makes a //! drop on an async executor's worker thread a blocking call elsewhere. +//! - **[`StateWriterHandle::flush`] is the only way to wait for a write.** It +//! queues a marker behind every write handed off so far and blocks until +//! the thread reaches it, which is how +//! [`Store::prune_finalized_states`](crate::store::Store::prune_finalized_states) +//! keeps a queued write below its cut from landing after its range delete. //! - **A write that panics is not retried, hidden, or cleaned up after.** The //! entry stays in [`PendingStates`] (see its doc), and the next hand-off //! observes the closed channel and fails loudly, rather than silently @@ -61,8 +66,7 @@ use crate::beacon_state_delta; use crate::error::Error; use crate::state_codec::{decode_lean_state_value, decode_state_value, encode_state_value}; use crate::state_diff::StateDiff; -use crate::store::Chain; -use crate::store::beacon_block_slot; +use crate::store::{Chain, beacon_block_slot, block_slot, block_slot_in, encode_slot_root_key}; /// What a state insert will write, decided but not yet committed. /// @@ -169,9 +173,15 @@ pub(crate) fn read_state( let state = match chain { Chain::Lean => { // Anchor snapshot in `States`, otherwise reconstruct from the diff chain. + // `States` is keyed by block slot then root, so a root with no + // block row has no state to find. + let Some(slot) = block_slot(backend, chain, root) else { + return Ok(None); + }; let snapshot = { let view = backend.begin_read().expect("read view"); - view.read_with(Table::States, &root.to_ssz(), decode_lean_state_value) + let key = encode_slot_root_key(slot, root); + view.read_with(Table::States, &key, decode_lean_state_value) .expect("read") }; let state = match snapshot { @@ -258,10 +268,15 @@ fn rebase_onto_resident(cache: &StateCache, state: &mut BeaconState) { /// [`Cow::Owned`], so taking ownership costs no further copy either way /// beyond the one a borrowed snapshot needs. /// +/// Both tables are keyed by block slot then root, so each hop looks up its +/// own slot from that root's `BlockHeaders` row. +/// /// `Ok(None)` when `root` is unknown, or the chain runs off the retained /// window before reaching a snapshot: a missing `StateDiffs` record below -/// the pruned boundary, matching how the lean walk in -/// [`reconstruct_state`] handles both cases. +/// the pruned boundary (see +/// [`Store::prune_finalized_states`](crate::store::Store::prune_finalized_states)), +/// matching how the lean walk in [`reconstruct_state`] handles both cases. A +/// hop whose block row is absent ends the walk the same way. pub(crate) fn reconstruct_beacon_state_bytes( backend: &dyn StorageBackend, root: &H256, @@ -272,7 +287,10 @@ pub(crate) fn reconstruct_beacon_state_bytes( let mut consume = Some(consume); let mut cursor = *root; loop { - let key = cursor.to_ssz(); + let Some(slot) = block_slot_in(view.as_ref(), Chain::Beacon, &cursor) else { + return Ok(None); + }; + let key = encode_slot_root_key(slot, &cursor); // The walk ends at the first snapshot, so the deltas collected so // far are folded onto it while it is still borrowed. let folded = view @@ -345,7 +363,10 @@ pub(crate) fn reconstruct_state( let mut diffs: Vec = Vec::new(); let mut cursor = *root; let snapshot = loop { - let key = cursor.to_ssz(); + let Some(slot) = block_slot_in(view.as_ref(), Chain::Lean, &cursor) else { + return Ok(None); + }; + let key = encode_slot_root_key(slot, &cursor); if let Some(snapshot) = view .read_with(Table::States, &key, decode_lean_state_value) .expect("read") @@ -494,6 +515,17 @@ pub(crate) struct StateWriteRequest { pub state: Arc, } +/// What travels down the writer's channel, served strictly in order. +enum Message { + Write(StateWriteRequest), + /// Answered once every message queued before it has been served; see + /// [`StateWriterHandle::flush`]. + Flush(SyncSender<()>), + /// Test-only: parks the thread until the paired sender sends or drops. + #[cfg(test)] + Pause(Receiver<()>), +} + /// The writer thread's own state. /// /// Holds the individual `Arc`s it needs rather than a `Store`, which would be @@ -514,14 +546,23 @@ struct StateWriter { } impl StateWriter { - fn run(mut self, rx: Receiver) { - for request in rx { - self.write(&request); - // Only now, after the commit returned: until this line both the - // buffer and the backend can answer for this root, and after it - // the backend alone can. There is no instant where neither does. - self.pending.remove(&request.root); - crate::metrics::dec_state_write_queue_depth(); + fn run(mut self, rx: Receiver) { + for message in rx { + match message { + Message::Write(request) => { + self.write(&request); + // Only now, after the commit returned: until this line both + // the buffer and the backend can answer for this root, and + // after it the backend alone can. There is no instant where + // neither does. + self.pending.remove(&request.root); + crate::metrics::dec_state_write_queue_depth(); + } + // The flusher may have given up waiting; nothing to do then. + Message::Flush(done) => drop(done.send(())), + #[cfg(test)] + Message::Pause(resume) => drop(resume.recv()), + } } } @@ -561,7 +602,14 @@ impl StateWriter { write.snapshot.is_some() || write.diff.is_some(), "a state write that persists nothing would silently drop the state" ); - let key = root.to_ssz(); + // Keyed by the slot of the block the state belongs to, which is what a + // reader holding only a root recovers from `BlockHeaders`. Not + // `state.slot()`: a beacon anchor state can be advanced past its block. + let block_slot = match &*request.state { + BeaconState::Lean(lean) => lean.latest_block_header.slot, + beacon_state => beacon_state.latest_block_header().slot, + }; + let key = encode_slot_root_key(block_slot, &root); let mut batch = self.backend.begin_write().expect("write batch"); if let Some(diff) = write.diff { batch @@ -625,7 +673,7 @@ pub(crate) struct StateWriterHandle { /// `Option` so [`Drop`] can take it. Dropping the sender is what ends the /// thread's `recv` loop, and it has to happen before the join or the join /// waits forever. - tx: Option>, + tx: Option>, join: Option>, } @@ -667,12 +715,44 @@ impl StateWriterHandle { /// learning about it, one state later. pub(crate) fn send(&self, request: StateWriteRequest) -> (Instant, Instant) { let start = Instant::now(); + self.send_message(Message::Write(request)); + (start, Instant::now()) + } + + /// Blocks until every state handed off before this call has committed. + /// + /// The marker queues behind those writes like any other message, so this + /// waits for at most a full queue plus the write in flight, the same bound + /// a blocked [`send`](Self::send) already has. The writer never waits on a + /// caller, so this cannot deadlock. + /// + /// # Panics + /// + /// If the writer thread is gone or dies before reaching the marker, for + /// the same reason [`send`](Self::send) does. + pub(crate) fn flush(&self) { + let (done_tx, done_rx) = sync_channel(1); + self.send_message(Message::Flush(done_tx)); + done_rx + .recv() + .expect("the state writer thread died; see the logged panic above"); + } + + /// Test-only: parks the writer once it reaches this point in the queue, + /// until the returned sender sends or is dropped. + #[cfg(test)] + pub(crate) fn pause(&self) -> SyncSender<()> { + let (resume_tx, resume_rx) = sync_channel(1); + self.send_message(Message::Pause(resume_rx)); + resume_tx + } + + fn send_message(&self, message: Message) { self.tx .as_ref() .expect("the sender is taken only in Drop") - .send(request) + .send(message) .expect("the state writer thread died; see the logged panic above"); - (start, Instant::now()) } } diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index ae74dc3ee..ebf3f4e17 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -1,5 +1,6 @@ use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::num::NonZeroUsize; +use std::ops::Range; use std::sync::{Arc, LazyLock, Mutex, MutexGuard}; use std::time::Instant; @@ -28,8 +29,8 @@ use ethlambda_types::{ }, fork::ForkName, fork_choice::{ - BlockPayloadLink, LatestMessage, PayloadStatus, PayloadStatusEnum, PayloadStatusV1, - PowBlock, + BlockPayloadLink, JustifiedBalances, LatestMessage, PayloadStatus, PayloadStatusEnum, + PayloadStatusV1, PowBlock, }, preset::{PTC_SIZE, Preset, SLOTS_PER_EPOCH}, primitives::ExecutionBlockHash, @@ -167,6 +168,15 @@ const KEY_BEACON_UNREALIZED_FINALIZED: &[u8] = b"beacon_unrealized_finalized"; /// [`KEY_LATEST_FINALIZED`] is seeded to the anchor too, but moves with the /// chain, so after the first finalization nothing else on disk can answer this. const KEY_ANCHOR_SLOT: &[u8] = b"anchor_slot"; +/// The exclusive upper bound of every slot [`Store::prune_finalized_states`] +/// has already deleted from `States` and `StateDiffs`, which is also the start +/// of the next range it will delete. Its value has type [`u64`] and it's +/// SSZ-encoded; absent means nothing has been pruned yet, i.e. zero. +/// +/// Written in the same atomic batch as the range deletes it describes, so the +/// watermark and the tombstones can never disagree, and consecutive ranges +/// stay adjacent rather than overlapping, across restarts included. +const KEY_STATES_PRUNED_BELOW: &[u8] = b"states_pruned_below"; /// The on-disk format this build reads and writes. /// /// Bumped whenever a table's key or value layout changes. `from_db_state` @@ -193,7 +203,13 @@ const KEY_ANCHOR_SLOT: &[u8] = b"anchor_slot"; /// 6 widened `Config` with the gloas schedule, timing and churn keys /// (`max_request_payloads` was already claimed before this version bump), so a /// directory written by version 5 decodes into the wrong fields. -pub const DB_VERSION: u64 = 6; +/// +/// 7 moved `States` and `StateDiffs` from block-root keys to slot||root keys, +/// so finalized states can be dropped with a range delete (see +/// [`Store::prune_finalized_states`]). A directory written by the previous +/// version holds rows no lookup can find, and there is no migration, by the +/// same policy every previous change followed. +pub const DB_VERSION: u64 = 7; /// The consensus protocol a data directory holds. /// @@ -657,6 +673,73 @@ impl GossipSignatureBuffer { } } +/// Bound on a validator index [`Store::set_latest_message`] accepts in debug +/// builds. Far above any registry this client will meet, far below what a +/// corrupt index would need to exhaust memory: the dense vote table has one slot +/// per index up to the highest seen. +const MAX_PLAUSIBLE_VALIDATOR_INDEX: u64 = 1 << 26; + +/// How many epoch-boundary [`JustifiedBalances`] snapshots the store keeps. +const JUSTIFIED_BALANCES_CAPACITY: usize = 8; + +/// A small cache of [`JustifiedBalances`], keyed by their checkpoint. +/// +/// Filled at block import, for the epoch-boundary checkpoint a block makes +/// available, so that the tick which moves the justified checkpoint finds the +/// snapshot already built and the head computation never has to rebuild a +/// state. Lighthouse does the same with a plain 4-entry FIFO. Eviction here is +/// smarter, since a network with many forks (several blocks crossing one +/// boundary from different parents) could push the canonical entry out of a +/// FIFO within one epoch: +/// +/// 1. when full, the entry with the lowest epoch goes first (ties: the oldest +/// insert), because an old boundary is the least likely to be justified; +/// 2. the entry for the store's current justified checkpoint is never evicted. +#[derive(Default)] +pub(crate) struct JustifiedBalancesCache { + /// Insert sequence number (for tie-breaking) and the snapshot. + entries: Vec<(u64, Arc)>, + next_sequence: u64, +} + +impl JustifiedBalancesCache { + fn get(&self, checkpoint: &BeaconCheckpoint) -> Option> { + self.entries + .iter() + .find(|(_, balances)| balances.checkpoint() == *checkpoint) + .map(|(_, balances)| Arc::clone(balances)) + } + + /// Adds `balances`, evicting by the policy above if the cache is full. + /// `current_justified` is the entry that must survive. + fn insert(&mut self, balances: Arc, current_justified: &BeaconCheckpoint) { + let sequence = self.next_sequence; + self.next_sequence += 1; + let checkpoint = balances.checkpoint(); + if let Some(entry) = self + .entries + .iter_mut() + .find(|(_, existing)| existing.checkpoint() == checkpoint) + { + *entry = (sequence, balances); + return; + } + if self.entries.len() >= JUSTIFIED_BALANCES_CAPACITY { + let victim = self + .entries + .iter() + .enumerate() + .filter(|(_, (_, entry))| entry.checkpoint() != *current_justified) + .min_by_key(|(_, (sequence, entry))| (entry.checkpoint().epoch, *sequence)) + .map(|(index, _)| index); + if let Some(victim) = victim { + self.entries.swap_remove(victim); + } + } + self.entries.push((sequence, balances)); + } +} + /// Beacon fork-choice state that is per-slot or per-epoch scratch rather than /// chain history: apart from the entries named below, nothing here /// survives a restart, and nothing else here is worth the write @@ -721,7 +804,14 @@ pub(crate) struct BeaconScratch { /// the same deadline it always checked. pub(crate) block_timeliness: HashMap, pub(crate) equivocating_indices: HashSet, - pub(crate) latest_messages: HashMap, + /// Dense, indexed by validator index: the vote loop reads votes and + /// snapshot balances in index order, not hash order. `None` is a validator + /// that has not voted (or an index below the highest one seen). + pub(crate) latest_messages: Vec>, + /// Epoch-boundary balances, flattened for the vote loop + /// and keyed by their own checkpoint. A derived cache: a miss is rebuilt + /// from `checkpoint_states`, so nothing needs to persist it. + pub(crate) justified_balances: JustifiedBalancesCache, pub(crate) pow_blocks: HashMap, /// In-memory half of `Table::BeaconUnrealizedJustifications`; see this /// struct's own documentation for why this map, unlike its neighbours, @@ -821,7 +911,10 @@ pub(crate) struct BeaconScratch { /// Encode a LiveChain key (slot, root) to bytes. /// Layout: slot (8 bytes big-endian) || root (32 bytes) /// Big-endian ensures lexicographic ordering matches numeric ordering. -fn encode_slot_root_key(slot: u64, root: &H256) -> Vec { +/// +/// Also the key of `States` and `StateDiffs`, where the slot is the slot of the +/// block the state belongs to (see [`block_slot`]). +pub(crate) fn encode_slot_root_key(slot: u64, root: &H256) -> Vec { let mut result = slot.to_be_bytes().to_vec(); result.extend_from_slice(&root.0); result @@ -929,6 +1022,22 @@ fn builds_on_payload(block: &SignedBeaconBlock, payload_block_hash: &ExecutionBl } } +/// Reads a beacon block's slot straight out of a `BlockHeaders` value, without +/// decoding the block. +/// +/// The value is a fork selector byte followed by the SSZ of a +/// `SignedBeaconBlock`, which is `{ message: BeaconBlock (variable), signature }`: +/// its fixed part opens with a 4-byte little-endian offset to `message`, and +/// `BeaconBlock`'s first field is `slot: u64`. The offset is read rather than +/// assumed, so a fork that grows the fixed part of the signed block keeps this +/// correct. A full decode would materialise the whole body just to learn eight +/// bytes, and this runs on every state read. +pub(crate) fn peek_beacon_block_slot(value: &[u8]) -> u64 { + let (_, ssz) = value.split_first().expect("value is never empty"); + let offset = u32::from_le_bytes(ssz[..4].try_into().expect("offset bytes")) as usize; + u64::from_le_bytes(ssz[offset..offset + 8].try_into().expect("slot bytes")) +} + /// `root`'s slot on a beacon chain, read without a `Store`, for the writer /// thread's anchor decision. /// @@ -936,18 +1045,46 @@ fn builds_on_payload(block: &SignedBeaconBlock, payload_block_hash: &ExecutionBl /// first-ever beacon state is recognised: it has no parent block, so there is /// no base to diff against and it is always a snapshot. /// -/// Beacon directories only: it decodes the row as a tagged -/// [`SignedBeaconBlock`], which is the wrong shape for a lean directory's bare -/// [`BlockHeader`] (unlike [`block_fields`](Store::block_fields), which -/// dispatches on `self.chain`). Its callers are [`StateWriter::write`](crate::state_writer)'s -/// non-`Lean` match arm and the beacon-only envelope and resume methods on -/// [`Store`], so that invariant is enforced by every caller being a beacon -/// path rather than by a runtime check. +/// Beacon directories only: the row is a tagged [`SignedBeaconBlock`], which +/// is the wrong shape for a lean directory's bare [`BlockHeader`]. Use +/// [`block_slot`] when the chain is not known statically. pub(crate) fn beacon_block_slot(backend: &dyn StorageBackend, root: &H256) -> Option { let view = backend.begin_read().expect("read view"); - view.read_with(Table::BlockHeaders, &root.to_ssz(), |bytes| { - decode_beacon_block_value(bytes).slot() - }) + block_slot_in(view.as_ref(), Chain::Beacon, root) +} + +/// `root`'s block slot on either chain, or `None` when no block is on record. +/// +/// This is the slot `States` and `StateDiffs` are keyed by: a state lives at +/// the slot of the block it belongs to, which is the slot in that root's +/// `BlockHeaders` row and equals the state's own `latest_block_header.slot`. +/// It is deliberately not `state.slot()`: a beacon checkpoint-sync anchor state +/// can be advanced past its block (a skipped boundary slot), and a reader that +/// only has a root can recover the block's slot but not the state's. +/// +/// A root with no block row has no state either, so every state reader treats +/// `None` as "absent" rather than as an error. +pub(crate) fn block_slot(backend: &dyn StorageBackend, chain: Chain, root: &H256) -> Option { + let view = backend.begin_read().expect("read view"); + block_slot_in(view.as_ref(), chain, root) +} + +/// [`block_slot`] through a view the caller already holds. +/// +/// A walk that keeps one view open across its hops has to use this: opening a +/// second read view while holding the first can deadlock against a writer +/// queued between the two on a backend whose read lock is not reentrant. +pub(crate) fn block_slot_in(view: &dyn StorageReadView, chain: Chain, root: &H256) -> Option { + match chain { + Chain::Beacon => { + view.read_with(Table::BlockHeaders, &root.to_ssz(), peek_beacon_block_slot) + } + Chain::Lean => view.read_with(Table::BlockHeaders, &root.to_ssz(), |bytes| { + BlockHeader::from_ssz_bytes(bytes) + .expect("valid header") + .slot + }), + } .expect("read") } @@ -958,7 +1095,7 @@ pub(crate) fn beacon_block_slot(backend: &dyn StorageBackend, root: &H256) -> Op /// - **Metadata**: time, config, head, safe_target, justified/finalized checkpoints /// - **Blocks**: headers and bodies stored separately for efficient header-only queries /// - **BlockRoots**: canonical block roots indexed by slot -/// - **States**: beacon states indexed by block root +/// - **States**: post-states indexed by block slot then root (finalized intervals pruned) /// - **Attestations**: latest known and pending ("new") attestations per validator /// - **Signatures**: gossip signatures and aggregated proofs for signature verification /// - **LiveChain**: slot index for efficient fork choice traversal (pruned on finalization) @@ -1567,9 +1704,11 @@ impl Store { // State snapshot. The anchor has no parent in the store, so it is // the base of every diff chain: store it as a full snapshot in - // `States` (never pruned) so reconstruction always terminates here. + // `States` so reconstruction always terminates here. It is only + // removed by `prune_finalized_states`, once a later snapshot + // interval is finalized and no kept state's walk reaches it. let state_entries = vec![( - anchor_block_root.to_ssz(), + encode_slot_root_key(anchor_slot, &anchor_block_root), encode_state_value(&BeaconState::Lean(anchor_state.clone())), )]; batch @@ -2096,11 +2235,14 @@ impl Store { Ok(()) } - /// Prune finalized block proofs to keep proof storage bounded. + /// Prune finalized data to keep storage bounded. /// - /// State diffs, block headers, block bodies, and full-state snapshots are - /// all retained for the full history and are never pruned. Only proofs - /// of finalized blocks older than the pruning window are removed. + /// Block headers and bodies are retained for the full history. Proofs of + /// finalized blocks older than the pruning window are removed, and so are + /// the states (snapshots and diffs) of finalized snapshot intervals; see + /// [`prune_finalized_states`](Self::prune_finalized_states). This is the + /// lean call site for the latter; beacon prunes from the chain actor's + /// finality horizon instead. /// /// This is separated from `update_checkpoints` so callers can defer heavy /// pruning until after a batch of blocks has been fully processed. @@ -2120,6 +2262,13 @@ impl Store { if pruned_below_slot > 0 { info!(pruned_below_slot, "Pruned old finalized block proofs"); } + if let Some(pruned) = self.prune_finalized_states()? { + info!( + from_slot = pruned.start, + below_slot = pruned.end, + "Pruned finalized states" + ); + } Ok(()) } @@ -2375,6 +2524,119 @@ impl Store { Ok(cutoff) } + /// Deletes the states of finalized snapshot intervals, as one range delete + /// per call, and returns the slot range it covered (`None` when nothing + /// new could be deleted). + /// + /// `States` and `StateDiffs` are keyed by slot||root, so everything below a + /// slot is one contiguous key range. Each call deletes + /// `[watermark, cut)`, where the watermark ([`KEY_STATES_PRUNED_BELOW`]) is + /// where the previous call stopped, and stores `cut` as the new watermark in + /// the same atomic batch. Consecutive ranges therefore start exactly where + /// the last one ended and never overlap, even across a restart, and a + /// repeated call with unchanged finality is a single metadata read. + /// + /// `cut` is the start of the snapshot interval that holds the finalized + /// **block**, `b / interval * interval`, where `b` is that block's own slot + /// taken from its `BlockHeaders` row. It is not + /// [`latest_finalized`](Self::latest_finalized)`().slot`: on beacon the + /// stored checkpoint is epoch-denominated, so its slot is the epoch start, + /// which sits above the block whenever the boundary slot was skipped, and + /// cutting there would delete the finalized state itself. The interval is + /// the one of the fork at the finalized block's epoch, so a future per-fork + /// interval is not silently wrong; on lean it is + /// [`ForkName::Lean`]'s. + /// + /// # Why nothing kept can reference a deleted key + /// + /// Every state the node can still be asked for is the finalized block's or + /// a descendant's. A diff-chain walk stops at the first snapshot on its + /// branch. The first block of any branch inside a snapshot interval is a + /// snapshot (`is_anchor`: its parent sits in an earlier interval), and the + /// store's first-ever state is always one, so every such walk stops at or + /// after `cut`: no kept row references a deleted key. + /// + /// Blocks below finality are never imported again (lean's + /// `process_or_pend_block` drops `slot <= finalized`; beacon's `on_block` + /// requires a finalized descendant), so no state is written below the + /// watermark after it moves. That is what makes "never revisit a range" + /// sound, provided nothing handed off *before* finality moved lands after + /// the delete. [`insert_state`](Self::insert_state) only queues its write, + /// so a state of a block imported just before finality advanced past it + /// may still be waiting on the writer thread; committed after the + /// tombstones, it would survive them below the watermark for good. The + /// writer is therefore flushed first, and only once there is a range to + /// delete, so the common no-op call never waits on it. + /// + /// The bare 8-byte slot prefixes are exact range bounds for slot||root + /// keys: keys below a slot sort before its prefix, and keys at that slot + /// extend it with a root and so sort after it (see + /// [`prune_old_block_proofs`](Self::prune_old_block_proofs)). + pub fn prune_finalized_states(&mut self) -> Result>, Error> { + let watermark = self.states_pruned_below(); + let finalized = self.latest_finalized()?; + + // The checkpoint's slot is never below its block's, so if even that + // upper bound's interval start is at or below the watermark there is + // nothing to prune, and the block read is skipped. This runs after + // every tick and import on beacon, so the common case is the two + // metadata reads above. + let hint_interval = self.snapshot_interval_at(finalized.slot); + if finalized.slot / hint_interval * hint_interval <= watermark { + return Ok(None); + } + + let Some(block_slot) = block_slot(self.backend.as_ref(), self.chain, &finalized.root) + else { + return Ok(None); + }; + let interval = self.snapshot_interval_at(block_slot); + let cut = block_slot / interval * interval; + if cut <= watermark { + return Ok(None); + } + + // Every write queued before this call commits before the range delete + // below, so none can resurrect a key under the new watermark. + self.state_writer.flush(); + let mut batch = self.backend.begin_write().expect("write batch"); + for table in [Table::States, Table::StateDiffs] { + batch + .delete_range(table, &watermark.to_be_bytes(), &cut.to_be_bytes()) + .expect("delete finalized states"); + } + let watermark_entries = vec![(KEY_STATES_PRUNED_BELOW.to_vec(), cut.to_ssz())]; + batch + .put_batch(Table::Metadata, watermark_entries) + .expect("put states pruned below"); + batch.commit().expect("commit"); + + Ok(Some(watermark..cut)) + } + + /// The slot below which finalized states have already been deleted; zero + /// before the first prune. + fn states_pruned_below(&self) -> u64 { + let view = self.backend.begin_read().expect("read view"); + view.read_with(Table::Metadata, KEY_STATES_PRUNED_BELOW, |bytes| { + u64::from_ssz_bytes(bytes).expect("valid states pruned below") + }) + .expect("read") + .unwrap_or(0) + } + + /// The snapshot interval of the fork in force at `slot`. + /// + /// Lean has one. On beacon it goes through the fork schedule rather than a + /// shared constant, so a per-fork interval would be picked up here. + fn snapshot_interval_at(&self, slot: u64) -> u64 { + let fork = match self.chain { + Chain::Lean => ForkName::Lean, + Chain::Beacon => self.config.fork_at_epoch(slot / SLOTS_PER_EPOCH), + }; + fork.snapshot_interval() + } + /// Get the block header by root. pub fn get_block_header(&self, root: &H256) -> Result, Error> { if self.chain != Chain::Lean { @@ -3022,7 +3284,9 @@ impl Store { } // A cached block state is always one that was handed to the writer // (`insert_state`, so in `pending_states` until committed) or read back - // from the backend (`read_state`); nothing deletes a persisted state. + // from the backend (`read_state`). A cached state can be one whose rows + // `prune_finalized_states` has since deleted; that is fine, because + // nothing below finality is ever asked for as a parent again. // `peek`, not `get`: an existence check must not reorder the LRU. // Without this, a state stored as a snapshot only (a beacon epoch // anchor) costs a full-value read from RocksDB just to be found. @@ -3035,8 +3299,12 @@ impl Store { { return Ok(true); } + // Both tables are keyed by block slot then root; no block row, no state. + let Some(slot) = block_slot(self.backend.as_ref(), self.chain, root) else { + return Ok(false); + }; let view = self.backend.begin_read().expect("read view"); - let key = root.to_ssz(); + let key = encode_slot_root_key(slot, root); // Diffs first: every non-anchor state has one, so most roots never // touch `States`, where even a miss is costly (no bloom filter). Ok(view.contains(Table::StateDiffs, &key).expect("contains") @@ -3057,10 +3325,11 @@ impl Store { /// it off. /// /// Lean: a parent-linked diff, snapshotting at anchors. Every non-genesis - /// state gets a `StateDiffs` entry (never pruned, so the full state - /// history is preserved). A full snapshot is written to `States` only - /// when the block crosses a [`ForkName::snapshot_interval`] boundary; - /// these anchors are never pruned and bound the reconstruction walk. The + /// state gets a `StateDiffs` entry. A full snapshot is written to `States` + /// only when the block crosses a [`ForkName::snapshot_interval`] boundary; + /// these anchors bound the reconstruction walk. Both tables are keyed by + /// the slot of the state's own block, then its root, and are pruned below + /// finality by [`prune_finalized_states`](Self::prune_finalized_states). The /// state is also inserted into the in-memory cache so the immediate next /// read (e.g. as a child block's parent state) is hot without /// reconstruction. The diff is built against the parent state, identified @@ -3954,17 +4223,28 @@ impl Store { .lock() .unwrap() .latest_messages - .get(&index) + .get(usize::try_from(index).ok()?) .copied() + .flatten() } /// Records the latest attestation for validator `index`. + /// + /// The table grows to hold `index`, so an index must come from a validated + /// attestation (a member of a committee of the registry), never from raw + /// input: a wild index would allocate a table to match. The assertion is + /// the tripwire for that in debug builds and fixtures. pub fn set_latest_message(&mut self, index: u64, message: LatestMessage) { - self.beacon - .lock() - .unwrap() - .latest_messages - .insert(index, message); + debug_assert!( + index < MAX_PLAUSIBLE_VALIDATOR_INDEX, + "validator index {index} would grow the dense vote table far past any registry" + ); + let slot = usize::try_from(index).expect("validator index fits in usize"); + let mut beacon = self.beacon.lock().unwrap(); + if beacon.latest_messages.len() <= slot { + beacon.latest_messages.resize(slot + 1, None); + } + beacon.latest_messages[slot] = Some(message); } /// Gloas: the payload timeliness committee's per-member votes on whether @@ -4011,7 +4291,8 @@ impl Store { } /// Calls `f` with `(validator_index, latest_message)` for every latest - /// message whose validator has not been observed equivocating. + /// message whose validator has not been observed equivocating, in + /// ascending validator index. /// /// Takes a closure rather than returning an iterator or a cloned map: /// the data lives behind a mutex, so a borrow of it cannot escape the @@ -4020,13 +4301,50 @@ impl Store { /// let it count for either side of the fork it created. pub fn for_each_non_equivocating_latest_message(&self, mut f: impl FnMut(u64, LatestMessage)) { let beacon = self.beacon.lock().unwrap(); - for (&index, &message) in &beacon.latest_messages { - if !beacon.equivocating_indices.contains(&index) { - f(index, message); + // Most of the time nobody has equivocated, so skip the set probe per vote. + let any_equivocators = !beacon.equivocating_indices.is_empty(); + for (index, message) in beacon.latest_messages.iter().enumerate() { + let Some(message) = message else { + continue; + }; + let index = index as u64; + if any_equivocators && beacon.equivocating_indices.contains(&index) { + continue; } + f(index, *message); } } + /// The cached balances snapshot for exactly `checkpoint`, if there is one. + /// + /// The checkpoint is the whole key, so a caller never has to know which + /// code moved the justified checkpoint: an unknown checkpoint just misses. + pub fn justified_balances( + &self, + checkpoint: &BeaconCheckpoint, + ) -> Option> { + self.beacon + .lock() + .unwrap() + .justified_balances + .get(checkpoint) + } + + /// Adds a balances snapshot to the cache, replacing one for the same + /// checkpoint. See [`JustifiedBalancesCache`] for the eviction policy. + /// + /// Takes `&self`, like [`Self::cache_state`]: the read-only fork-choice + /// helpers fill it on a miss. + pub fn insert_justified_balances(&self, balances: Arc) { + // Read before taking the lock: it is the one entry eviction must keep. + let current_justified = self.beacon_justified_checkpoint(); + self.beacon + .lock() + .unwrap() + .justified_balances + .insert(balances, ¤t_justified); + } + /// Looks up a PoW block by its own hash, standing in for the /// specification's `get_pow_block(hash)`. pub fn beacon_pow_block(&self, hash: H256) -> Option { @@ -4893,7 +5211,7 @@ mod tests { .put_batch( Table::States, vec![( - root.to_ssz(), + encode_slot_root_key(state.latest_block_header.slot, &root), encode_state_value(&BeaconState::Lean(state.clone())), )], ) @@ -4910,10 +5228,11 @@ mod tests { .count() } - /// Check if a key exists in a table. - fn has_key(backend: &dyn StorageBackend, table: Table, root: &H256) -> bool { + /// Check if a state row exists at `slot || root` in `table`. + fn has_state_row(backend: &dyn StorageBackend, table: Table, slot: u64, root: &H256) -> bool { let view = backend.begin_read().expect("read view"); - view.contains(table, &root.to_ssz()).expect("contains") + view.contains(table, &encode_slot_root_key(slot, root)) + .expect("contains") } /// Check whether a block proof exists for a (slot, root) pair. @@ -6461,10 +6780,13 @@ mod tests { (store, counts) } + /// Writes a junk row for `root` at slot 1, with the block row a reader + /// needs to find the slot it is keyed under. fn put_raw(store: &Store, table: Table, root: H256) { + insert_header(store.backend.as_ref(), root, 1, H256::ZERO); let mut batch = store.backend.begin_write().expect("write batch"); batch - .put_batch(table, vec![(root.to_ssz(), vec![1, 2, 3])]) + .put_batch(table, vec![(encode_slot_root_key(1, &root), vec![1, 2, 3])]) .expect("put"); batch.commit().expect("commit"); } @@ -6663,7 +6985,7 @@ mod tests { let view = backend.begin_read().expect("read view"); let value = view - .get(Table::States, &anchor.to_ssz()) + .get(Table::States, &encode_slot_root_key(0, &anchor)) .expect("get") .expect("anchor snapshot written at bootstrap"); assert_eq!(value[0], ForkName::Lean.selector()); @@ -6685,7 +7007,12 @@ mod tests { genesis_validators_root: H256::ZERO, slot, fork: Default::default(), - latest_block_header: Default::default(), + // The state is keyed by its block's slot, which is this header's: + // the block is `beacon_test_block(slot, ..)`. + latest_block_header: ethlambda_types::beacon::containers::BeaconBlockHeader { + slot, + ..Default::default() + }, block_roots: vec![H256::ZERO; preset::SLOTS_PER_HISTORICAL_ROOT] .try_into() .expect("the vector is built at its exact length"), @@ -6930,6 +7257,8 @@ mod tests { let mut child = parent; *child.slot_mut() = 11; child.latest_block_header_mut().parent_root = parent_root; + // The state is keyed by its block's slot, which is the header's. + child.latest_block_header_mut().slot = 11; *child.balance_mut(0).unwrap() += 1; child.apply_pending_mutations(); store @@ -6951,6 +7280,84 @@ mod tests { assert_eq!(decoded.to_ssz(), child.to_ssz()); } + /// The tree-backed fields beyond the registry share with the resident + /// parent too, across a state whose scores and previous participation did + /// not change but whose balances did. + #[test] + fn a_decoded_electra_state_shares_its_tree_fields_with_the_resident_parent() { + use crate::beacon_state_delta::tests::electra_state_with_validators; + use ethlambda_types::beacon::containers::HistoricalSummary; + + let backend: Arc = Arc::new(InMemoryBackend::new()); + let mut store = beacon_test_store(backend.clone()); + let parent_root = H256::from([1u8; 32]); + let child_root = H256::from([2u8; 32]); + + let mut electra = electra_state_with_validators(600); + for i in 0..4u8 { + let summary = HistoricalSummary { + block_summary_root: H256::from([i; 32]), + state_summary_root: H256::from([i + 1; 32]), + }; + electra.historical_summaries.push(summary).unwrap(); + } + let mut parent = BeaconState::Electra(electra); + // States are keyed by their header's slot and read back by their + // block's, so each state names its own block's slot there. + parent.latest_block_header_mut().slot = 10; + parent.apply_pending_mutations(); + store + .insert_signed_block(parent_root, beacon_test_block(10, H256::ZERO)) + .expect("insert parent block"); + store + .insert_state(parent_root, parent.clone()) + .expect("insert parent state"); + + let mut child = parent; + *child.slot_mut() += 1; + child.latest_block_header_mut().parent_root = parent_root; + child.latest_block_header_mut().slot = 11; + *child.balance_mut(0).unwrap() += 1; + child.block_roots_mut()[10] = parent_root; + child.apply_pending_mutations(); + store + .insert_signed_block(child_root, beacon_test_block(11, parent_root)) + .expect("insert child block"); + store + .insert_state(child_root, child.clone()) + .expect("insert child state"); + + drop(store); + let cold = beacon_test_store(backend); + let resident = cold.get_state(&parent_root).expect("get").expect("present"); + let decoded = cold.get_state(&child_root).expect("get").expect("present"); + + assert!(decoded.validators_ptr_eq(&resident)); + let (_, _, decoded_scores) = decoded.altair_validator_lists().unwrap(); + let (_, _, resident_scores) = resident.altair_validator_lists().unwrap(); + assert!(decoded_scores.ptr_eq(&resident_scores)); + assert!(decoded.randao_mixes().ptr_eq(resident.randao_mixes())); + assert!(decoded.slashings().ptr_eq(resident.slashings())); + assert!( + decoded + .historical_roots() + .ptr_eq(resident.historical_roots()) + ); + assert!(decoded.eth1_data_votes().ptr_eq(resident.eth1_data_votes())); + let (BeaconState::Electra(decoded_electra), BeaconState::Electra(resident_electra)) = + (decoded.as_ref(), resident.as_ref()) + else { + panic!("both states are electra"); + }; + assert!(!resident_electra.historical_summaries.is_empty()); + assert!( + decoded_electra + .historical_summaries + .ptr_eq(&resident_electra.historical_summaries) + ); + assert_eq!(decoded.to_ssz(), child.to_ssz()); + } + #[test] fn a_cold_beacon_state_folds_every_delta_back_from_its_snapshot() { let backend: Arc = Arc::new(InMemoryBackend::new()); @@ -7880,7 +8287,12 @@ mod tests { ); let anchor_root = store.head().expect("Failed to get head block root"); - assert!(has_key(backend.as_ref(), Table::States, &anchor_root)); + assert!(has_state_row( + backend.as_ref(), + Table::States, + 0, + &anchor_root + )); } // ============ from_db_state Tests ============ @@ -8403,6 +8815,33 @@ mod tests { assert_eq!(seen, vec![1]); } + #[test] + fn latest_messages_are_visited_in_validator_index_order_and_gaps_are_skipped() { + let mut store = Store::test_store(); + let message = |epoch| LatestMessage { + epoch, + slot: epoch * SLOTS_PER_EPOCH, + root: H256::from([epoch as u8; 32]), + payload_present: false, + }; + + // Written out of order, with a gap, and one overwritten. + store.set_latest_message(9, message(1)); + store.set_latest_message(2, message(2)); + store.set_latest_message(5, message(3)); + store.set_latest_message(5, message(4)); + + let mut seen = Vec::new(); + store + .for_each_non_equivocating_latest_message(|index, vote| seen.push((index, vote.epoch))); + assert_eq!(seen, vec![(2, 2), (5, 4), (9, 1)]); + + assert_eq!(store.latest_message(5).map(|vote| vote.epoch), Some(4)); + assert_eq!(store.latest_message(3), None, "a gap is not a vote"); + assert_eq!(store.latest_message(1_000), None, "past the table"); + assert_eq!(store.latest_message(u64::MAX), None); + } + #[test] fn a_pow_block_is_looked_up_by_its_own_hash() { let mut store = Store::test_store(); @@ -9372,4 +9811,419 @@ mod tests { assert!(index.contains_key(&kept)); assert!(!index.contains_key(&removed)); } + + // ============ Finalized State Pruning Tests ============ + + /// The slot a beacon block's row yields through the cheap read must be the + /// slot a full decode yields, for every fork shape the table can hold. + #[test] + fn peeking_a_beacon_block_slot_equals_decoding_it() { + use ethlambda_types::beacon::containers::{altair, electra, gloas}; + + let altair_block = SignedBeaconBlock::Altair(altair::SignedBeaconBlock { + message: altair::BeaconBlock { + slot: 0x0102_0304_0506, + proposer_index: 3, + parent_root: root(1), + state_root: root(2), + body: Default::default(), + }, + signature: Default::default(), + }); + let electra_block = |slot| electra::SignedBeaconBlock { + message: electra::BeaconBlock { + slot, + proposer_index: 9, + parent_root: root(3), + state_root: root(4), + body: electra::BeaconBlockBody::empty(), + }, + signature: Default::default(), + }; + let blocks = [ + beacon_test_block(0, H256::ZERO), + beacon_test_block(13_000_000, root(5)), + altair_block, + SignedBeaconBlock::Electra(electra_block(77)), + SignedBeaconBlock::Fulu(electra_block(u64::MAX - 1)), + SignedBeaconBlock::Gloas(gloas::SignedBeaconBlock { + message: gloas::BeaconBlock { + slot: 0x0a0b_0c0d, + proposer_index: 5, + parent_root: root(6), + state_root: root(7), + body: gloas::BeaconBlockBody::empty(), + }, + signature: Default::default(), + }), + ]; + // The fork tag is part of the value; the newest fork in `ALL` is among + // the blocks above. + assert_eq!(blocks[5].fork_name(), *ForkName::ALL.last().unwrap()); + for block in blocks { + let value = encode_beacon_block_value(&block); + assert_eq!( + peek_beacon_block_slot(&value), + decode_beacon_block_value(&value).slot(), + "fork {:?}", + block.fork_name() + ); + } + } + + /// Writes a lean block's header row and a snapshot for it, so a state + /// exists at `slot` and a reader can find its key. + fn seed_lean_snapshot(backend: &dyn StorageBackend, block_root: H256, slot: u64) -> State { + // `historical_block_hashes` is one entry per slot, which is what a + // child's diff is checked against. + let state = sample_state(slot, root(900), vec![H256::ZERO; slot as usize]); + insert_header(backend, block_root, slot, root(900)); + insert_snapshot(backend, block_root, &state); + state + } + + fn set_finalized(store: &Store, block_root: H256, slot: u64) { + store.set_metadata( + KEY_LATEST_FINALIZED, + &Checkpoint { + root: block_root, + slot, + }, + ); + } + + #[test] + fn pruning_finalized_states_keeps_the_finalized_state_and_its_descendants() { + let interval = ForkName::Lean.snapshot_interval(); + let backend = Arc::new(InMemoryBackend::new()); + let store = Store::from_anchor_state( + backend.clone(), + State::from_genesis(1_000, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + let genesis_root = store.head().expect("head"); + drop(store); + + // Below the cut: an old snapshot, and a diff row (a junk value is fine, + // since it is only ever deleted). + let old = root(1); + seed_lean_snapshot(backend.as_ref(), old, 100); + let junk_diff = root(2); + let mut batch = backend.begin_write().expect("write batch"); + batch + .put_batch( + Table::StateDiffs, + vec![(encode_slot_root_key(101, &junk_diff), vec![1, 2, 3])], + ) + .expect("put diff"); + batch.commit().expect("commit"); + + // The finalized block opens the interval that starts at `2 * interval`, + // so it is a snapshot; its child is a diff on it. + let finalized_slot = 2 * interval + 5; + // The root is the header's real hash: the child's diff appends it to + // `historical_block_hashes`, and that append is validated against it. + let finalized_state = sample_state( + finalized_slot, + root(900), + vec![H256::ZERO; finalized_slot as usize], + ); + let finalized_root = finalized_state.latest_block_header.hash_tree_root(); + insert_header(backend.as_ref(), finalized_root, finalized_slot, root(900)); + insert_snapshot(backend.as_ref(), finalized_root, &finalized_state); + let mut child_hbh = finalized_state.historical_block_hashes.to_vec(); + child_hbh.push(finalized_root); + let child_state = sample_state(finalized_slot + 1, finalized_root, child_hbh); + let child_root = root(4); + insert_header( + backend.as_ref(), + child_root, + finalized_slot + 1, + finalized_root, + ); + let mut writer = Store::test_store_with_backend(backend.clone()); + writer + .insert_state(child_root, BeaconState::Lean(child_state.clone())) + .expect("insert child state"); + drop(writer); + + let mut store = Store::test_store_with_backend(backend.clone()); + set_finalized(&store, finalized_root, finalized_slot); + + let pruned = store.prune_finalized_states().expect("prune"); + assert_eq!(pruned, Some(0..2 * interval)); + assert_eq!( + store.get_metadata::(KEY_STATES_PRUNED_BELOW), + 2 * interval + ); + + // The deleted rows are gone, from both tables. + let b = backend.as_ref(); + assert!(!has_state_row(b, Table::States, 0, &genesis_root)); + assert!(!has_state_row(b, Table::States, 100, &old)); + assert!(!has_state_row(b, Table::StateDiffs, 101, &junk_diff)); + assert!(has_state_row( + b, + Table::States, + finalized_slot, + &finalized_root + )); + + // A fresh store has an empty cache, so these reads really hit the + // tables: the finalized snapshot, and the child rebuilt from its diff. + let cold = Store::test_store_with_backend(backend.clone()); + let read = cold + .get_state(&finalized_root) + .expect("get") + .expect("finalized state survives"); + assert_eq!(read.to_ssz(), BeaconState::Lean(finalized_state).to_ssz()); + let read = cold + .get_state(&child_root) + .expect("get") + .expect("descendant survives"); + assert_eq!(read.to_ssz(), BeaconState::Lean(child_state).to_ssz()); + assert!(cold.get_state(&old).expect("get").is_none()); + } + + #[test] + fn consecutive_state_prunes_cover_adjacent_ranges_and_persist_the_watermark() { + let interval = ForkName::Lean.snapshot_interval(); + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::from_anchor_state( + backend.clone(), + State::from_genesis(1_000, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + + // Finality still inside the anchor's own interval: nothing to prune. + assert_eq!(store.prune_finalized_states().expect("prune"), None); + + let first = root(1); + let first_slot = 2 * interval + 1; + seed_lean_snapshot(backend.as_ref(), first, first_slot); + set_finalized(&store, first, first_slot); + assert_eq!( + store.prune_finalized_states().expect("prune"), + Some(0..2 * interval) + ); + // Unchanged finality: the watermark answers without a second delete. + assert_eq!(store.prune_finalized_states().expect("prune"), None); + + let second = root(2); + let second_slot = 5 * interval + 7; + seed_lean_snapshot(backend.as_ref(), second, second_slot); + set_finalized(&store, second, second_slot); + assert_eq!( + store.prune_finalized_states().expect("prune"), + Some(2 * interval..5 * interval) + ); + assert_eq!(store.prune_finalized_states().expect("prune"), None); + + // The watermark outlives the store: a reopened one carries on from it. + drop(store); + let mut reopened = Store::from_db_state(backend.clone()) + .expect("restore store") + .expect("store exists"); + assert_eq!(reopened.prune_finalized_states().expect("prune"), None); + let third = root(3); + let third_slot = 6 * interval; + seed_lean_snapshot(backend.as_ref(), third, third_slot); + set_finalized(&reopened, third, third_slot); + assert_eq!( + reopened.prune_finalized_states().expect("prune"), + Some(5 * interval..6 * interval) + ); + } + + /// `insert_state` only queues its write, so finality can move past a state + /// still waiting on the writer thread. Committed after the range delete, + /// that write would survive it below the watermark, and no later prune + /// would revisit the range. + #[test] + fn a_state_write_queued_before_a_prune_does_not_survive_it() { + let interval = ForkName::Lean.snapshot_interval(); + let backend = Arc::new(InMemoryBackend::new()); + + // A parent with a snapshot, so the queued child's diff has a base. + let parent_state = sample_state(1, root(900), vec![H256::ZERO]); + let parent_root = parent_state.latest_block_header.hash_tree_root(); + insert_header(backend.as_ref(), parent_root, 1, root(900)); + insert_snapshot(backend.as_ref(), parent_root, &parent_state); + let child_state = sample_state(2, parent_root, vec![H256::ZERO, parent_root]); + + let finalized = root(1); + let finalized_slot = 3 * interval + 1; + seed_lean_snapshot(backend.as_ref(), finalized, finalized_slot); + + // Park the writer, then queue the child's write behind it. The parent + // is read first so it is cached: its snapshot is about to be pruned, + // and the writer needs it as the diff base either way. + let mut store = Store::test_store_with_backend(backend.clone()); + store.get_state(&parent_root).expect("get").expect("parent"); + let resume = store.state_writer.pause(); + store + .insert_state(root(2), BeaconState::Lean(child_state)) + .expect("insert child state"); + set_finalized(&store, finalized, finalized_slot); + + // Prune while that write is still queued. Give it time to finish + // ahead of the write if it does not wait for it, then let the writer + // go either way. + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let mut pruner = store.clone(); + let prune = std::thread::spawn(move || { + let pruned = pruner.prune_finalized_states().expect("prune"); + done_tx.send(()).expect("test is waiting"); + pruned + }); + let _ = done_rx.recv_timeout(std::time::Duration::from_millis(500)); + drop(resume); + let pruned = prune.join().expect("prune thread"); + assert_eq!(pruned, Some(0..3 * interval)); + // Dropping the last clone joins the writer, so every write has landed. + drop(store); + + let cut = 3 * interval; + let view = backend.begin_read().expect("read view"); + for table in [Table::States, Table::StateDiffs] { + let below_cut: Vec<_> = view + .prefix_iterator(table, &[]) + .expect("iterator") + .map(|entry| entry.expect("entry").0) + .filter(|key| u64::from_be_bytes(key[..8].try_into().unwrap()) < cut) + .collect(); + assert!( + below_cut.is_empty(), + "{table:?} kept {} row(s) below the watermark", + below_cut.len() + ); + } + } + + /// A beacon checkpoint is epoch-denominated: its slot is the epoch start, + /// above the finalized block's own slot whenever the boundary was skipped. + /// Cutting at the checkpoint's slot would delete the finalized state. + #[test] + fn a_beacon_finalized_block_below_its_epoch_start_is_not_pruned() { + let interval = ForkName::Phase0.snapshot_interval(); + let backend: Arc = Arc::new(InMemoryBackend::new()); + let mut store = beacon_test_store(backend.clone()); + + // `a` is in the first interval; `b` opens the third (a snapshot), and + // `c` is the finalized block, a diff on `b` just below epoch 3's start. + let (a_slot, b_slot, c_slot) = (10, 2 * interval + 1, 2 * interval + 5); + let (a, b, c) = (root(1), root(2), root(3)); + let mut parent = H256::ZERO; + for (block_root, slot) in [(a, a_slot), (b, b_slot), (c, c_slot)] { + store + .insert_signed_block(block_root, beacon_test_block(slot, parent)) + .expect("insert block"); + store + .insert_state(block_root, beacon_test_state_with_parent(slot, parent)) + .expect("insert state"); + parent = block_root; + } + drop(store); + + let mut cold = beacon_test_store(backend.clone()); + set_finalized(&cold, c, 3 * interval); + assert!(3 * interval > c_slot); + + let pruned = cold.prune_finalized_states().expect("prune"); + assert_eq!(pruned, Some(0..2 * interval)); + + assert!(cold.get_state(&a).expect("get").is_none()); + for (block_root, slot) in [(b, b_slot), (c, c_slot)] { + let state = cold + .get_state(&block_root) + .expect("get") + .expect("kept state is readable"); + assert_eq!(state.slot(), slot); + } + } + + // ---- justified-balances cache ---- + + fn snapshot(epoch: u64, root_byte: u8) -> Arc { + Arc::new(JustifiedBalances::new( + BeaconCheckpoint { + epoch, + root: H256::repeat_byte(root_byte), + }, + vec![1].into(), + 1, + vec![1].into(), + )) + } + + fn cached(cache: &JustifiedBalancesCache, snapshot: &JustifiedBalances) -> bool { + cache.get(&snapshot.checkpoint()).is_some() + } + + #[test] + fn the_balances_cache_is_bounded_and_evicts_the_lowest_epoch_first() { + let mut cache = JustifiedBalancesCache::default(); + let current = snapshot(100, 0); + // Inserted in an order that is not epoch order, so "lowest epoch" + // differs from "oldest insert". + let epochs = [5, 3, 9, 4, 8, 7, 6, 10]; + let entries: Vec<_> = epochs.iter().map(|&epoch| snapshot(epoch, 1)).collect(); + for entry in &entries { + cache.insert(Arc::clone(entry), ¤t.checkpoint()); + } + assert_eq!(cache.entries.len(), JUSTIFIED_BALANCES_CAPACITY); + + let newest = snapshot(11, 1); + cache.insert(Arc::clone(&newest), ¤t.checkpoint()); + assert_eq!(cache.entries.len(), JUSTIFIED_BALANCES_CAPACITY); + assert!(cached(&cache, &newest)); + assert!(!cached(&cache, &entries[1]), "epoch 3, the lowest, is gone"); + assert!(cached(&cache, &entries[3]), "epoch 4 survives for now"); + + cache.insert(snapshot(12, 1), ¤t.checkpoint()); + assert!(!cached(&cache, &entries[3]), "then epoch 4"); + assert_eq!(cache.entries.len(), JUSTIFIED_BALANCES_CAPACITY); + } + + #[test] + fn equal_epochs_evict_the_oldest_insert_first() { + let mut cache = JustifiedBalancesCache::default(); + let current = snapshot(100, 0); + let forks: Vec<_> = (0..JUSTIFIED_BALANCES_CAPACITY as u8) + .map(|byte| snapshot(7, byte)) + .collect(); + for fork in &forks { + cache.insert(Arc::clone(fork), ¤t.checkpoint()); + } + cache.insert(snapshot(7, 200), ¤t.checkpoint()); + assert!(!cached(&cache, &forks[0]), "the oldest of the tie goes"); + assert!(cached(&cache, &forks[1])); + } + + #[test] + fn the_current_justified_entry_is_never_evicted() { + let mut cache = JustifiedBalancesCache::default(); + // The lowest epoch of all, and the one that must survive. + let current = snapshot(1, 0); + cache.insert(Arc::clone(¤t), ¤t.checkpoint()); + for epoch in 2..2 + 3 * JUSTIFIED_BALANCES_CAPACITY as u64 { + cache.insert(snapshot(epoch, 1), ¤t.checkpoint()); + assert!(cached(&cache, ¤t), "after inserting epoch {epoch}"); + assert!(cache.entries.len() <= JUSTIFIED_BALANCES_CAPACITY); + } + } + + #[test] + fn inserting_a_known_checkpoint_replaces_it_without_evicting() { + let mut cache = JustifiedBalancesCache::default(); + let current = snapshot(100, 0); + for epoch in 0..JUSTIFIED_BALANCES_CAPACITY as u64 { + cache.insert(snapshot(epoch, 1), ¤t.checkpoint()); + } + let again = snapshot(0, 1); + cache.insert(Arc::clone(&again), ¤t.checkpoint()); + assert_eq!(cache.entries.len(), JUSTIFIED_BALANCES_CAPACITY); + let found = cache.get(&again.checkpoint()).expect("still cached"); + assert!(Arc::ptr_eq(&found, &again), "the new snapshot replaced it"); + } } diff --git a/docs/beacon_stf.md b/docs/beacon_stf.md index 541317f36..b2df5a0f2 100644 --- a/docs/beacon_stf.md +++ b/docs/beacon_stf.md @@ -525,6 +525,30 @@ Two new handlers, `on_execution_payload_envelope` and `on_payload_attestation_message`, join the spec's list of ways to change the store. +Fork choice has the same problem on a longer loop: `get_head` weighs every +validator's latest vote by the voter's balance at the justified checkpoint, and +the boost needs the total balance of that state's active, unslashed +validators. Both read one +`JustifiedBalances` snapshot (`ethlambda-types`, held in the store's +`BeaconScratch`) instead of a `validator(i)` descent per vote and a registry +scan per boost. It is built from `checkpoint_state(justified)` only, keyed by +the checkpoint it came from, so a moved justified checkpoint is a miss with no +hook on the writers; it holds a zero for validators that are inactive or +slashed, and the sum of what it holds, floored at one increment. That sum +leaves slashed-but-active validators out, as `calculate_committee_fraction` +does since consensus-specs #5679 (v1.7.0-beta.4), so it is not +`get_total_active_balance`. +Equivocations stay out of it: they are store-level and can change at any time, +so they are filtered per vote. `get_weight` stays as the specification's +per-root definition, and the fork-choice fixture runner checks +`compute_weights` against it at every `checks` step. + +The latest votes are stored the same way, as a dense table indexed by validator +index rather than a hash map, so the vote loop reads votes and snapshot +balances in index order. The table grows to the highest voting index, which is +why `set_latest_message` only takes indices from validated attestations (a +debug assertion bounds it). + ## Macros and traits Two `macro_rules!` in the whole crate, both local, both replacing boilerplate that @@ -593,6 +617,35 @@ eleven cores busy to about eight. The 3.3x understates the hashing change, because the later run does strictly more work: `transition` went from failing immediately to running every case. +## Epoch-transition precompute + +The first block of an epoch used to run `process_epoch` inline on the import +path (`fork_choice::on_block`, then `process_slots`), followed by the rehash +the state-root check needs. The beacon chain actor now does that work ahead of +time, on a blocking worker, and the import resumes from the result. + +| Step | What happens | +| --- | --- | +| Trigger: head | An import leaves a block at the last slot of epoch `E` as head: precompute `(E+1, head_root)`. Covers a late last-slot block. | +| Trigger: timer | Three quarters into the last slot of `E` (derived from the configured slot duration), if nothing for the current head is cached or running: precompute from the head, which is an earlier block when the last slot was skipped. | +| Gate | Skipped while the sync tracker says syncing. The head trigger skips a head more than a slot behind the wall clock; the timer trigger skips a head before `E`'s first slot, since the tracker reports a stalled network as synced and advancing such a head would replay every epoch it missed. Either way a worker crosses one epoch boundary at most. At most one worker runs; a key already cached is not recomputed. | +| Work | Clone the head state, `process_slots` to the first slot of `E+1`, flush pending writes, `hash_tree_root` (so the tree nodes' hashes are memoized), send the state back to the actor. | +| Store | The store's state cache under `CacheKey::CheckpointState { epoch: E+1, root: head_root }`. `fork_choice::checkpoint_state` derives the same value for that checkpoint (the checkpoint block's post-state advanced to the epoch's first slot), so attestation targets for `E+1` hit it too. | +| Consume | `on_block` looks up `CheckpointState { block_epoch, parent_root }` when the parent's slot is before the epoch's first slot and the block is at or after it. A hit clones the entry, advances any remaining skipped slots, and applies the block with `stf::apply_block`; the post-state is identical to the inline path's. | +| Miss | Today's path: clone the parent state and run `stf::state_transition`. A block that arrives while the worker is still running takes this path too; the late result is stored and goes unused by it. | + +The code lives in `crates/blockchain/src/epoch_precompute.rs` (triggers, worker, +actor handlers) and `fork_choice::advance_to_epoch_start` / +`fork_choice::transition_block` in the state-transition crate. The upgrade to a +new fork at the boundary happens inside `process_slots`, so the precompute needs +no fork-specific code. Gloas is covered by the same key: a gloas block's +post-state never contains its own payload (the next block applies it inside +`process_block`, after `process_slots`), and the store keeps exactly one state +per root, so the entry for `(E+1, root)` is advanced from the same state an +import of a child of `root` would clone. Hits and misses, worker time and +trigger counts are exported as `lean_beacon_epoch_precompute_*`; see +[metrics](metrics.md). + ## One test per fixture case The suites were once one test apiece, each looping over its own cases and diff --git a/docs/beacon_wire.md b/docs/beacon_wire.md index 447170b11..6c5e9947b 100644 --- a/docs/beacon_wire.md +++ b/docs/beacon_wire.md @@ -253,6 +253,89 @@ goes through fanout), all under the digest the block's slot falls in. Operations posted to the `pool/*` routes are published on their topics the same way, under the digest of the wall-clock slot. +### Peer scoring + +Gossipsub peer scoring is on for this wire only +(`crate::beacon::scoring`). The parameters are lighthouse's +(`gossipsub_scoring_parameters.rs`), ported formula for formula; Grandine, +Teku and Lodestar use the same constants, so thresholds mean the same thing +here as on most of mainnet. + +| Threshold | Score | Effect | +| --- | --- | --- | +| gossip | -4000 | no IHAVE/IWANT to or from the peer | +| publish | -8000 | left out of this node's publishes | +| graylist | -16000 | every RPC ignored, and the peer is disconnected | + +| Topic | Weight | Mesh deliveries (P3) | +| --- | --- | --- | +| `beacon_block` | 0.5 | scored | +| `beacon_aggregate_and_proof` | 0.5 | scored | +| `beacon_attestation_{0..63}` | 1/64 each | scored | +| `voluntary_exit`, `proposer_slashing`, `attester_slashing` | 0.05 each | off | + +Columns, sync committee contributions and BLS changes carry no topic +parameters. Only Prysm scores columns. This node `Ignore`s every contribution +and change (no consumer), and a delivery is only credited once accepted, so +scoring P3 there would penalize every mesh peer for this node's own gap. All +64 attestation subnets get parameters, not just the backbone ones, because +aggregator duties join others at runtime. + +Two differences from lighthouse: + +- `mesh_n` is this node's `D` (8), not lighthouse's 5. It only enters the + first-message-delivery cap. +- **P3 waits for this node to keep up.** Lighthouse joins these topics only + once synced, so it has no mesh while it catches up. This node subscribes at + startup, and while catching up it `Ignore`s every aggregate and attestation + voting for a block it has not imported. A mesh peer credited with no + aggregates scores below the graylist, so a restart would graylist the whole + aggregate mesh because of this node's lag. `MeshDeliveryGate` turns P3 off + while the head lags the wall clock by more than + `MESH_DELIVERY_MAX_HEAD_LAG` slots, and back on only after an epoch within + that lag, so the counters have refilled. While P3 is off its threshold and + weight are zero but its cap is not, so the counters keep counting to their + real ceiling. `SyncStatus` cannot serve as this gate: on the mainnet + follower it reported `synced` through a 10-minute catch-up up to 98 slots + behind, since its network-stall rule reads a lagging freshest-known block + as a stalled network. + +The block, aggregate and attestation parameters depend on the active +validator count, so the p2p actor rebuilds them every slot from the head's +pinned current-epoch shuffling (`CommitteeCache::head_current_committees`) +and hands them to the swarm. The swarm starts on lighthouse's placeholder (32 +validators, P3 off) until the first refresh, which runs at startup. Every 10 +s the swarm task disconnects peers below the graylist. There is no ban list: +a peer's negative score is kept for `retain_score` (100 epochs) after it +leaves, so a peer that reconnects comes back graylisted and is dropped again. + +Around a fork-digest boundary the node holds two digests' topics, and every +held digest gets the whole parameter set (exits and slashings included), +scored by its role: + +| Digest | Topic weights | P3 | +| --- | --- | --- | +| upcoming, joined `SUBSCRIBE_LEAD_EPOCHS` ahead | as above | off: nothing is published under it yet | +| current | as above | gated as above | +| previous, held `UNSUBSCRIBE_LAG_EPOCHS` after | 0 | off | + +- **Parameters before joins.** `transition::apply` hands the swarm every held + digest's parameters before it subscribes, on the same command channel. + Gossipsub tracks a mesh peer on a topic only if the topic had parameters + when the peer was grafted; parameters installed later never reach that + mesh. +- **The previous digest weighs nothing.** Its traffic moves to the new + digest at the boundary, so its delivery counters drain. Still scored, the + aggregate topic alone would graylist an honest mesh peer 53 slots after the + boundary (at 1M validators), before the topic is left, and leaving it would + charge every mesh peer the sticky P3b. Lighthouse zeroes the same weights + (`remove_topic_weight_except`). +- **A switch restarts the warmup.** The new digest's counters start empty, + but its mesh, formed before the boundary, can already be past the + deliveries' activation (one epoch for blocks and aggregates). P3 on the new + digest therefore waits `MESH_DELIVERY_WARMUP_SLOTS`, as after a catch-up. + Lighthouse joins two slots ahead, so activation alone covers it there. + ## Aggregate attestations `beacon_aggregate_and_proof` reaches fork choice. It is how a follower learns @@ -720,6 +803,9 @@ as a query filter, so a `quic`-only record is invisible to it. | `lean_beacon_aggregate_end_to_end_seconds` | Wire to fork choice, for aggregates applied on arrival | | `lean_beacon_aggregate_total{outcome}` | Aggregates by `applied`, `invalid`, `known_subset` or `queue_full` | | `lean_beacon_aggregates_deferred` | Aggregates held until their own slot has passed | +| `lean_gossipsub_peers_by_score{band}` | Gossipsub peers by score band; see [Peer scoring](#peer-scoring) | +| `lean_gossipsub_score_disconnects_total` | Peers disconnected for a score below the graylist | +| `lean_gossipsub_mesh_delivery_scoring` | 1 while P3 is scored, 0 while the head lags or is warming up | | `lean_beacon_publish_data_columns_seconds` | Time to compute and verify a published block's cells and build its sidecars; see [metrics.md](./metrics.md) | The four aggregate histograms no longer cover what they used to: gossip diff --git a/docs/data_storage.md b/docs/data_storage.md index 421ae02b4..781acbecd 100644 --- a/docs/data_storage.md +++ b/docs/data_storage.md @@ -128,8 +128,8 @@ The thirteen variants of the `Table` enum (`crates/storage/src/api/tables.rs`): | `BlockBodies` | root | `BlockBody` (lean only) | never | | `BlockProof` | slot ‖ root | aggregate proof (`MultiMessageAggregate`) | yes: finalized older than ~1 day | | `BlockRoots` | slot | block root (`H256`) | never | -| `States` | root | full `State` snapshot | never | -| `StateDiffs` | root | `StateDiff` | never | +| `States` | slot ‖ root | full `State` snapshot | yes: finalized snapshot intervals | +| `StateDiffs` | slot ‖ root | `StateDiff` | yes: finalized snapshot intervals | | `Metadata` | string | SSZ scalars | never | | `LiveChain` | slot ‖ root | `parent_root` | yes: below finalized | | `DataColumns` | slot ‖ root ‖ column_index | `DataColumnSidecar` (SSZ-encoded) | no (see [DataColumns](#datacolumns) below) | @@ -144,11 +144,17 @@ Four key layouts are used: - **Root-keyed** tables use the 32-byte SSZ encoding of the block root (`root.to_ssz()`). -- **Slot-prefixed** tables (`BlockProof`, `LiveChain`) use - `encode_slot_root_key`: an 8-byte **big-endian** slot followed by the - 32-byte root. Big-endian means lexicographic key order equals numeric slot - order, so pruning can iterate from the start of the table and stop at the - first key past its cutoff instead of scanning everything. +- **Slot-prefixed** tables (`BlockProof`, `LiveChain`, `States`, + `StateDiffs`) use `encode_slot_root_key`: an 8-byte **big-endian** slot + followed by the 32-byte root. Big-endian means lexicographic key order + equals numeric slot order, so pruning can iterate from the start of the + table and stop at the first key past its cutoff, or drop a whole slot range + with one range delete. For `States` and `StateDiffs` the slot is the slot of + the block the state belongs to (the slot in that root's `BlockHeaders` row, + equal to the state's `latest_block_header.slot`), not `state.slot()`: a + beacon anchor state can be advanced past its block. A reader that only has + a root looks the slot up in `BlockHeaders`; a root with no block row has no + state. - **Slot-prefixed, then column** (`DataColumns`) extends the same `slot ‖ root` prefix with an 8-byte big-endian `column_index`, via `data_column_key`. A block's own sidecars therefore share one lexicographic @@ -236,11 +242,13 @@ is reachable there only by root, never by slot. ### States -`root → State` (full SSZ snapshot). Holds full-state snapshots **only**: the -bootstrap anchor written at initialization, plus one anchor whenever a block -crosses a `SNAPSHOT_ANCHOR_INTERVAL`-slot boundary. Never pruned — these +`slot ‖ root → State` (full SSZ snapshot). Holds full-state snapshots +**only**: the bootstrap anchor written at initialization, plus one anchor +whenever a block crosses a `SNAPSHOT_ANCHOR_INTERVAL`-slot boundary. These anchors are the base every diff chain resolves against, so reconstruction -always terminates. +always terminates. Pruned below the finalized block's snapshot interval (see +[Pruning](#pruning)), which never removes a snapshot a kept state's chain +still needs. The genesis validator registry is constant for the life of the chain (`validators` is fixed at genesis; the lean STF never mutates it), but it has @@ -250,9 +258,8 @@ no table of its own: it rides inside every `States` snapshot alongside ### StateDiffs -`root → StateDiff`. A parent-linked diff written for **every** non-genesis -state. Never pruned, so together with the snapshots this preserves the full -state history. See [State Storage](#state-storage-snapshots--diffs) for what a +`slot ‖ root → StateDiff`. A parent-linked diff written for **every** +non-anchor state, pruned together with `States`. See [State Storage](#state-storage-snapshots--diffs) for what a diff contains and how states are rebuilt. ### Metadata @@ -271,6 +278,7 @@ different checkpoints. | `config` | `Config` | both | The node's runtime configuration | | `head` | `H256` | both | Current fork choice head | | `anchor_slot` | `u64` | both | The slot this directory's chain begins at | +| `states_pruned_below` | `u64` | both | Exclusive upper bound of the `States`/`StateDiffs` slots already range-deleted; absent means 0 | | `safe_target` | `H256` | lean | Current safe target (see [lmd_ghost.md](lmd_ghost.md)) | | `latest_justified` | `Checkpoint` | both | Latest justified checkpoint | | `latest_finalized` | `Checkpoint` | both | Latest finalized checkpoint | @@ -608,7 +616,7 @@ has to work with. Storing a full `State` per block would be wasteful: most fields never change or change predictably. Instead, `insert_state` writes: -1. **Always** a `StateDiff` keyed by the block root, linked to its parent via +1. **Always** a `StateDiff` keyed by the block's slot and root, linked to its parent via `base_root` (the block's `parent_root`). 2. **Only at anchors** a full snapshot into `States`. A block is an anchor when it crosses a `SNAPSHOT_ANCHOR_INTERVAL` slot boundary relative @@ -694,8 +702,8 @@ sequence of independent write batches: ├─ 3. insert_state() cache + PendingStates insert (synchronous), │ (hands the state to the then a hand-off to the background writer │ storage crate's own writer thread, which commits - │ thread; see below) StateDiffs[root] - │ States[root] (anchors only) + │ thread; see below) StateDiffs[slot‖root] + │ States[slot‖root] (anchors only) │ on its own schedule, not this one │ └─ 4. update_head() Metadata: head @@ -766,10 +774,28 @@ processed): not needed for fork choice, reorg safety, or re-aggregation once outside the window. -**Never pruned, by design:** `BlockHeaders`, `BlockBodies`, `BlockRoots`, -`States`, `StateDiffs`, and `Metadata`. Headers, bodies, the canonical slot -index, and the snapshot+diff chain are the full historical record; only the -proof blobs and the (non-finalized) fork choice index are disposable. +- `prune_finalized_states`: range-deletes `States` and `StateDiffs` over + `[watermark, cut)`, where `cut` is the start of the snapshot interval that + holds the finalized **block** (its own slot from `BlockHeaders`, not the + checkpoint's epoch-start slot) and `watermark` is `states_pruned_below`, the + previous call's `cut`. The watermark is written in the same atomic batch as + the deletes, so consecutive ranges are adjacent and never overlap, even + across restarts. Safe because a diff-chain walk stops at the first snapshot + on its branch, and the first block of any branch inside an interval is a + snapshot, so every walk from a kept state stops at or after `cut`; blocks + below finality are never imported again, so nothing is written below the + watermark afterwards. A state handed to the writer thread *before* finality + moved can still be queued, though, so a call that has a range to delete + first flushes that thread (`StateWriterHandle::flush`), and every earlier + write commits before the tombstones do. Lean runs it inside + `prune_old_data`; beacon runs it from the chain actor's finality horizon, + after every tick and import, where the common case is one metadata read. + +**Never pruned, by design:** `BlockHeaders`, `BlockBodies`, `BlockRoots`, and +`Metadata`. Headers, bodies and the canonical slot index are the full +historical record (and the slot lookup every state read depends on); only the +proof blobs, the finalized states and the (non-finalized) fork choice index are +disposable. **Not pruned yet, unlike the six above:** `DataColumns`. It is not part of the historical-record set above; it simply has no pruning rule written for it, @@ -823,7 +849,7 @@ The first two funnel into `init_store`, which writes the anchor in **one atomic batch**: the `Metadata` keys a lean directory needs (the format version and the chain and preset tags, time, config, head = safe_target = anchor root, justified = finalized = anchor checkpoint), the anchor header, its -`BlockRoots` entry, the body if non-empty, a full snapshot into `States` (the +`BlockRoots` entry, the body if non-empty, a full snapshot into `States` keyed by the anchor's slot and root (the base of every future diff chain), and the anchor's `LiveChain` entry. `time` starts at genesis rather than at zero because it is an absolute timestamp, so genesis is the value that means "the clock has not moved yet"; every derived diff --git a/docs/metrics.md b/docs/metrics.md index 87ca59168..662e4f18e 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -426,6 +426,26 @@ instead. Neither pool has a metric of its own yet; a permit exhausted on either shows up as `outcome="ignore",reason="overloaded"` on `lean_beacon_gossip_validation_total`, for the topics that draw from it. +### Beacon Peer Scoring + +Gossipsub peer scoring runs on the beacon wire only; see +[beacon_wire.md](./beacon_wire.md#peer-scoring) for the parameters and the +mesh-delivery gate. These are ethlambda-specific, not part of the leanMetrics +spec. + +| Name | Type | Usage | Sample collection event | Labels | Buckets | +|------|------|-------|-------------------------|--------|---------| +| `lean_gossipsub_peers_by_score` | Gauge | Gossipsub peers by score band | Every 10 s, in the swarm task | band=non_negative,negative,below_gossip,below_publish,below_graylist | | +| `lean_gossipsub_score_disconnects_total` | Counter | Peers disconnected for a gossipsub score below the graylist (-16000) | When the 10 s check disconnects one | | | +| `lean_gossipsub_mesh_delivery_scoring` | Gauge | 1 while mesh message deliveries (P3) are scored, 0 while the head lags the wall clock or is warming up after catching up | Every slot, on the scoring refresh | | | + +The bands are mutually exclusive and split at the gossip (-4000), publish +(-8000) and graylist (-16000) thresholds, so they add up to the peers +gossipsub knows. `below_graylist` counts the peers that same pass is about to +disconnect, so it should not hold a value across passes. A persistent +`below_gossip`/`below_publish` population points at slow peers first: a full +send queue costs a peer 10 points per dropped message. + ### Beacon Block Publication `POST /eth/v2/beacon/blocks` computes every blob's cells and batch-verifies all @@ -483,6 +503,45 @@ Expect one miss per epoch per chain, and hits otherwise. `unkeyable` is the genesis state asking about its own epoch, and should be zero on a checkpoint-synced follower. +### Beacon Justified Balances + +Beacon fork choice weighs every vote by the voter's effective balance at the +justified checkpoint. `justified_balances` (in +`crates/blockchain/state_transition/src/beacon/fork_choice.rs`) flattens that +state into one array, keyed by the justified checkpoint and rebuilt on the first +`get_head` after the checkpoint moves. This is ethlambda-specific, not part of +the leanMetrics spec. + +| Name | Type | Usage | Sample collection event | Labels | +|------|------|-------|-------------------------|--------| +| `lean_beacon_justified_balances_lookups_total` | Counter | Snapshot lookups, by whether the cached snapshot served them | On every `justified_balances` call: `get_head`'s weights, the proposer boost, and the reorg helpers | result=hit,miss | +| `lean_beacon_justified_balances_build_seconds` | Histogram | Time to build one snapshot from the checkpoint state | On every miss, around the registry pass (the checkpoint state lookup is not included) | | + +**Read the miss rate against the justified-checkpoint rate**: about one miss +per justified checkpoint change, so a handful per hour on a healthy chain. +Misses that track `get_head` calls mean the justified checkpoint is flapping +between branches, or the snapshot is being replaced between two readers. + +### Beacon Epoch Precompute + +The beacon chain actor advances the head state across the next epoch boundary +ahead of time, so the first block of an epoch does not run `process_epoch` on +the import path. See "Epoch-transition precompute" in +[`beacon_stf.md`](beacon_stf.md). This is ethlambda-specific, not part of the +leanMetrics spec. + +| Name | Type | Usage | Sample collection event | Labels | Buckets | +|------|------|-------|-------------------------|--------|---------| +| `lean_beacon_epoch_precompute_lookups_total` | Counter | Epoch-crossing imports, by whether a precomputed boundary state was cached | On each import whose parent is before the block's epoch start and whose block is at or after it | result=hit,miss | | +| `lean_beacon_epoch_precompute_seconds` | Histogram | Worker time to advance, flush and hash one state | When a precompute worker finishes | | 0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 8 | +| `lean_beacon_epoch_precompute_started_total` | Counter | Workers started, by trigger | When a worker is spawned | trigger=head,timer | | + +**Read the hit ratio against epochs.** On a synced follower nearly every +epoch-crossing import should hit. Misses mean the worker had not finished (the +block raced it, see `lean_beacon_epoch_precompute_seconds` against the slot +duration), the node was syncing, or the entry was evicted from the state cache. +A `timer` start with no matching `head` start means the last slot was skipped. + ### Beacon Pubkey Cache Every BLS signature check `ethlambda beacon` runs (block import, fork choice, diff --git a/docs/spec_deviations.md b/docs/spec_deviations.md index bc8aec424..191be46cd 100644 --- a/docs/spec_deviations.md +++ b/docs/spec_deviations.md @@ -513,6 +513,31 @@ node, and each call walks every latest message and every ancestor step. every `head` check of every fork-choice fixture and on randomized trees; `compute_head` is the pre-gloas reference. +## The committee fraction leaves slashed validators out, ahead of the pinned release + +The proposer boost and both reorg thresholds follow a consensus-specs change +newer than the release the fixtures are pinned to. + +- **The pinned release (`v1.7.0-beta.2`):** `calculate_committee_fraction` + divides `get_total_active_balance`, which counts a validator that is slashed + but still active, and `get_proposer_score` does the same arithmetic inline. +- **ethlambda:** both divide the total balance of the active, unslashed + validators, as consensus-specs #5679 does (first released in + `v1.7.0-beta.4`). Fork choice weighs no vote from a slashed validator, so + during a mass slashing a boost sized against their stake outweighs a larger + share of the votes that still count, which makes a reorg easier. The + `JustifiedBalances` snapshot carries that total (`total_balance`) + beside each validator's raw effective balance, + which `is_head_weak` still adds back for equivocators in the head slot's + committees, slashed or not. +- **Other clients:** Lighthouse's `JustifiedBalances::from_justified_state` + already sums active, unslashed validators only. +- **Consequence:** none while no active validator in the justified state is + slashed. No `v1.7.0-beta.2` fork-choice fixture distinguishes the two: the + one that slashes validators in its anchor state ticks past the boosted slot + before it checks the head. Remove this entry when the pin reaches + `v1.7.0-beta.4`. + ## The execution client judges a gloas payload before the envelope is applied `verify_execution_payload_envelope` asks the execution engine last, as part of