From 429b6197e879dbf07414b3e3e0a4805d1a8f16ba Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Tue, 27 Oct 2026 15:35:38 -0300 Subject: [PATCH 1/6] feat(storage): prune finalized states with non-overlapping range deletes (#683) --- CLAUDE.md | 22 +- crates/blockchain/src/lib.rs | 21 + .../src/beacon/fork_choice.rs | 4 + .../src/beacon/gossip/envelope.rs | 9 +- .../src/beacon/gossip/proposer_preferences.rs | 3 + crates/storage/src/api/tables.rs | 21 +- crates/storage/src/backend/rocksdb.rs | 13 +- crates/storage/src/state_writer.rs | 118 +++- crates/storage/src/store.rs | 607 +++++++++++++++++- docs/data_storage.md | 70 +- 10 files changed, 795 insertions(+), 93 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a151a49d9..35591b236 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -961,6 +961,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 +991,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/lib.rs b/crates/blockchain/src/lib.rs index c90615340..c9dc378df 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -3199,6 +3199,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 +3255,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; } diff --git a/crates/blockchain/state_transition/src/beacon/fork_choice.rs b/crates/blockchain/state_transition/src/beacon/fork_choice.rs index 8d5343443..79733afc5 100644 --- a/crates/blockchain/state_transition/src/beacon/fork_choice.rs +++ b/crates/blockchain/state_transition/src/beacon/fork_choice.rs @@ -6132,6 +6132,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 +8592,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; 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/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/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..5faea3087 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; @@ -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. /// @@ -821,7 +837,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 +948,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 +971,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 +1021,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 +1630,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 +2161,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 +2188,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 +2450,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 +3210,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 +3225,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 +3251,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 @@ -4893,7 +5088,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 +5105,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 +6657,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 +6862,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 +6884,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 +7134,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 @@ -7880,7 +8086,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 ============ @@ -9372,4 +9583,334 @@ 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); + } + } } 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 From 7f4d24391c6c4b69ca2e67c2619e8b2ecd63ebe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:23:02 -0300 Subject: [PATCH 2/6] perf(beacon): keep inactivity scores and the ring buffers on the tree (#650) ## Motivation Now that `validators` and `balances` are persistent trees, every cached state still holds a private deep copy of the other large fields and rehashes them in full on each state root. | Field(s) | Mainnet size per state | Flat cost today | |---|---|---| | `inactivity_scores` | ~18 MiB | copied per state, rehashed per root, rewritten for every eligible index each epoch | | `randao_mixes` | ~2 MiB | same | | `block_roots`, `state_roots`, `slashings` | ~0.6 MiB | same | | `eth1_data_votes`, `historical_roots`, `historical_summaries` | ~0.25 MiB | same | Estimates, not measurements: ~26 MiB of flat fields per state and ~0.85M Merkle nodes rehashed per root, of which this PR moves the large majority of the nodes (participation lists and queues stay flat). The import-replay benchmark was not run for this PR, and no memory or node-count number was measured; the estimates come from the field sizes above. ## Changes | Commit | What | |---|---| | `perf(ssz-tree): flush tree fields through one Buffered list per fork` | Object-safe `Buffered` trait (impls for `List` and `Vector`); `tree_fields!` lists each fork's tree fields once and generates both `apply_pending_mutations` and `has_pending_mutations`. `H256` writes `HashTreeRoot` by hand and forwards `is_basic_type` (as `H160` does). New ssz-tree properties: multi-leaf `u8` lists, `u64` and 32-byte-newtype vectors, decode parity, rebase of mostly-zero lists and vectors. | | `perf(beacon): keep inactivity scores on the tree and skip no-op writes` | `InactivityScores` is a tree list (`BTreeMap` update map). `process_inactivity_updates` computes the same value with the same checked arithmetic and writes only `if new != score`. Joins the flush list and `BeaconState::rebase_on`. | | `perf(beacon): keep the roots and randao ring buffers on the tree` | `BlockRoots`, `StateRoots`, `RandaoMixes`, `Slashings` become tree vectors; `Eth1DataVotes`, `HistoricalRoots`, `HistoricalSummaries` become tree lists (alias swap). All join the flush list and `rebase_on`. Block production now flushes before it hashes the post-state. Test-only sites that sliced or iterated in place use indexing. | SSZ bytes, roots and the storage delta format are unchanged. ## Design decisions - **`H256` change done locally**, not in libssz's `transparent` derive (no dependency bump). The lean chain shares `H256`, so `ethlambda-types` has unit tests that an `SszList`/`SszVector` of `H256` hashes like one of `[u8; 32]`, and that a container holding `H256` collections keeps its root. The lean state's pinned genesis root test passes unchanged. - **`BTreeMap` update map for inactivity scores.** Outside a leak almost no score changes, so writes are sparse. A leak writes nearly every score, which costs about one B-tree insert per validator per epoch (~1M at the benchmark's registry size) until a bulk write path exists. A dense map (`VecMap`) was the alternative; it allocates 16 B per validator on the first push or write. - **Skip loop written by hand** in `process_inactivity_updates`: read the score, compute the new one exactly as before (overflow still raises `ArithmeticOverflow` before the recovery step), write only if different. A write rebuilds its leaf even for an equal value, which would unshare the list from the parent state's. - **`rebase_on` pairs scores through the altair accessors**, so it works across forks and skips phase0 (no scores). - **`process_slot` leaves its two roots writes buffered** until the next flush (the next slot's, or `process_slots`' last step). `process_historical_summaries_update` hashes the roots vectors with that write pending: correct, and the vectors are small. Tests pin this. - **Block production flush:** `produce_block` hashed the post-state without `apply_pending_mutations`, so the computed hashes were discarded; one added call. - `Buffered` and `tree_fields!` are additive in ssz-tree and `containers/mod.rs`, to keep a merge with other work in the same files easy. ## Not in this PR | Item | Why | |---|---| | Participation lists on the tree (`previous_`/`current_epoch_participation`) | Blocked on a zip pass for `get_unslashed_participating_indices` (#632): its per-index reads would cost ~2M tree descents per block in the pulled-up tip. Also needs `List::filled` for an O(1) rotation. | | `pending_deposits` on the tree | Depends on the mainnet queue length, which has not been measured. | | Sync committees (`Arc` + cached-root newtype) | Small win (~1.5 MiB per state), separate design. | | Rebase across an epoch boundary (decoded `previous` equals the base's `current`) | Positional rebase misses it; one `if` in `rebase_on`, left for the participation PR. | ## Testing | Command | Result | |---|---| | `cargo test -p ethlambda-ssz-tree --profile release-fast` | 35 passed (properties), 0 failed | | `cargo test -p ethlambda-types --profile release-fast --lib` | 201 passed | | `cargo test -p ethlambda-storage --profile release-fast --lib` | 139 passed, 1 ignored | | `cargo test -p ethlambda-state-transition --profile release-fast --lib` | 329 passed, 16 ignored | | `cargo test -p ethlambda-rpc --profile release-fast --lib` | 110 passed | | Beacon spec suite, mainnet preset (full) | 5734 passed, 152 ignored; 4 failed | | Beacon spec suite, minimal preset (full) | 40038 passed, 3692 ignored; 4 failed | | `cargo clippy` (workspace, and state-transition with `beacon-spec-tests`) with `-D warnings`, `cargo fmt --check`, `cargo check -p ethlambda` | clean | The 4 failures in each spec run are the `gossip/*` trials, which report "matched no fixture cases": the gossip fixture directory is intentionally empty in the local setup. Every `ssz_static`, `sanity`, `finality`, `random`, `epoch_processing` and `transition` trial passes. The lean `ssz_spectests` and the import-replay benchmark were not run. --------- Co-authored-by: Pablo Deymonnaz --- .../src/beacon/block_production.rs | 4 + .../src/beacon/stf/epoch/altair.rs | 98 +++++- .../src/beacon/stf/epoch/gloas.rs | 2 +- .../state_transition/src/beacon/stf/mod.rs | 115 +++++- .../state_transition/src/beacon/upgrade.rs | 4 +- .../tests/beacon_spec/epoch_processing.rs | 8 +- crates/common/ssz-tree/src/lib.rs | 13 + crates/common/ssz-tree/src/list.rs | 10 + .../common/ssz-tree/src/progressive_list.rs | 10 + crates/common/ssz-tree/src/vector.rs | 10 + crates/common/ssz-tree/tests/model.rs | 218 ++++++++++++ .../common/types/src/beacon/containers/mod.rs | 330 ++++++++++++++++-- .../types/src/beacon/containers/shared.rs | 41 ++- crates/common/types/src/primitives.rs | 110 +++++- crates/common/types/tests/beacon_json.rs | 121 ++++--- crates/net/rpc/src/beacon/ptc.rs | 4 +- crates/net/rpc/src/beacon/validator.rs | 4 +- crates/net/rpc/src/lib.rs | 18 +- crates/storage/src/beacon_state_delta.rs | 33 +- crates/storage/src/store.rs | 78 +++++ 20 files changed, 1089 insertions(+), 142 deletions(-) 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/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..8a57ca638 100644 --- a/crates/blockchain/state_transition/src/beacon/stf/mod.rs +++ b/crates/blockchain/state_transition/src/beacon/stf/mod.rs @@ -357,6 +357,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 +434,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 +457,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/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/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/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/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/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/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/store.rs b/crates/storage/src/store.rs index 5faea3087..734439904 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -7157,6 +7157,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()); From b14ff104a1f228a4ec7b73894ffd02c537c039f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:23:02 -0300 Subject: [PATCH 3/6] perf(beacon): weigh fork-choice votes from a justified-balances snapshot (#649) ## Motivation Since the registry moved onto a persistent tree, `state.validator(i)` is a tree descent instead of an array read. Two places in `get_head` pay one per validator: - `compute_weights` does a descent per latest vote, then a hash-order probe into the equivocator set and a SipHash map. - With a proposer boost set, `get_proposer_score` calls `get_total_active_balance`: an active-index `Vec`, then one descent per active index. At mainnet scale (estimate: about 1M voters and 1M active validators in a 2.4M registry) that is of the order of two million descents per `get_head`. The `get_head` regression after the tree PR was measured at 126 ms to 198 ms p50 in the import-replay benchmark; how much of it is these two loops was inferred from the code, not profiled. The import-replay benchmark was not run as part of this work, so this PR carries no timing claims. ## Changes Three commits (plus merges from `main`), so the PR can be split along the commit boundaries if preferred. | Commit | What | |---|---| | `perf(beacon): weigh fork-choice votes from a justified-balances snapshot` | `JustifiedBalances` (types crate): per-validator vote weight (0 if inactive or slashed) plus their total, built in one in-order pass over `checkpoint_state(justified)` and cached in `BeaconScratch`, keyed by its own checkpoint. `compute_weights` and `get_proposer_score` read it. `calculate_committee_fraction` is split into a pure `committee_fraction(total, percent)`. Metrics `lean_beacon_justified_balances_lookups_total{result}` and `lean_beacon_justified_balances_build_seconds`; docs in `docs/metrics.md` and `docs/beacon_stf.md`. | | `perf(beacon): keep latest messages dense, indexed by validator` | `BeaconScratch.latest_messages` becomes `Vec>`, so the vote loop walks votes and snapshot balances in index order and skips the equivocator probe while the set is empty. No API change. | | `fix(beacon): leave slashed validators out of the committee-fraction total` | Review fix. The committee-fraction total (proposer boost and both reorg thresholds) counts active, unslashed validators only, per consensus-specs #5679. The vote weights and the total share one predicate (`vote_balance`), so the snapshot's total is the sum of its balances; `JustifiedBalances::total_active_balance` is renamed `total_balance`. `calculate_committee_fraction` walks the registry once with `iter_validators()` and no longer returns `Result`. | Tests: - `get_weight` stays spec-literal as the oracle. The weights-vs-spec unit test now includes slashed, not-yet-active, exited and past-the-registry voters while a boost is set. - New unit tests: `build_justified_balances` edge cases (activation and exit at the epoch, slashed-but-active, all-inactive floor, index past the end); a new justified checkpoint rebuilds the snapshot; `get_proposer_score` leaves out a slashed-but-active validator (and agrees with `calculate_committee_fraction` on the state); dense storage table (index order, gaps, overwrite, equivocators). - The beacon `fork_choice` fixture runner now asserts, at every `checks` step, that `compute_weights` equals `get_weight` for every root of the filtered block tree. No fixture checks weights directly, so this puts the snapshot against the spec path on every case. It skips a root only where `get_weight` itself errors because invalidation removed the voted block (the `sync/*/optimistic/from_syncing_to_invalid` cases), since `compute_weights` drops those votes by design. It is the only edit to the runner. ## Design decisions | Question | Decision | |---|---| | Boost total | Active, unslashed validators only, floored at the increment, computed in the same pass as the snapshot (`saturating_add`). This is consensus-specs #5679 (v1.7.0-beta.4), ahead of the v1.7.0-beta.2 fixture pin, whose `calculate_committee_fraction` still uses `get_total_active_balance`; Lighthouse already excluded slashed stake (Lighthouse issue 10057). The state transition's `get_total_active_balance` is unchanged. | | Storage | `Box<[Gwei]>`, not `u16` increments (avoids an invariant to assert). | | Where it lives | A `BeaconScratch` field, not a new module. | | Snapshot source | Only `checkpoint_state(justified)`. A later post-state of the same epoch is not exact (`slashed` can change mid-epoch). Equivocations stay out of the snapshot and are filtered per vote. | | Cache key | The checkpoint itself, checked on every read, so no hook on the code that moves the justified checkpoint. | | `is_head_weak` / `is_parent_strong` | Not switched: their thresholds still come from `calculate_committee_fraction(checkpoint_state(justified))`. That is the same state the snapshot is built from, and both totals use `vote_balance`, so they cannot disagree. | | Dense vote bound | `debug_assert!` that an index is below a plausible bound before the table grows; the table has one slot per index up to the highest seen, so only validated attestation indices may reach it. Release builds do not check. | | Lock | Only the chain actor reaches the `BeaconScratch` lock on this path: no `crates/net` code calls the snapshot or vote accessors. | Deviation from the original sketch: the builder does not `debug_assert` that the state's epoch equals the checkpoint's epoch. Two existing unit-test stores pair an epoch-0 checkpoint with a state one epoch in, and activity is evaluated at the state's own epoch exactly as `get_weight` does, so correctness does not depend on it. ## Not in this PR - Prefetching the snapshot for the unrealized justified checkpoint (so the epoch-start pull-up finds it ready): wait for `get_head` max at epoch-start blocks after this PR. - Per-root running vote totals (delta weights): only worth it if a profile after this PR still shows the weight loop. - A proto-array style tree: a separate fork-choice project. - Optional single lock per attestation in `update_latest_messages` and a root-prefix hasher for the weights map. Open question: the replay harness never ticks, so the proposer boost never resets there and every replayed `get_head` pays the boost path. Should it tick? Out of scope here. ## Testing Run with `--profile release-fast`; spec fixtures from the existing local copy. | Command | Result | |---|---| | `cargo clippy --locked --workspace --all-targets -- -D warnings` | clean | | `cargo clippy -p ethlambda-state-transition --all-targets --features beacon-spec-tests -- -D warnings` | clean | | `cargo test -p ethlambda-types --lib` | 198 passed | | `cargo test -p ethlambda-storage --lib` | 139 passed, 1 ignored | | `cargo test -p ethlambda-state-transition --lib` | 327 passed, 16 ignored | | beacon spec suite, mainnet preset, full | 5734 passed, 152 ignored; 4 `gossip/*/matched_fixture_cases` fail only because the gossip fixture tree is empty in this environment | | beacon spec suite, minimal preset, full | 40038 passed, 3692 ignored; same 4 gossip trials | | `cargo check -p ethlambda` | ok | After the review fix (`ec477bc1`): | Command | Result | |---|---| | `make lint` | clean | | `cargo test -p ethlambda-types -p ethlambda-state-transition -p ethlambda-storage --lib` | 607 + 189 + 227 passed | | beacon spec suite, minimal preset, `fork_choice` (v1.7.0-beta.2) | 416 passed | | v1.7.0-beta.4's `get_head/proposer_boost_excludes_slashed_validators`, copied into the local tree (altair to gloas) | 7 passed; 7 failed before the fix | | beacon spec suite, mainnet preset | not rerun locally; CI's `test-beacon-mainnet` covers it | Co-authored-by: Pablo Deymonnaz --- .../src/beacon/fork_choice.rs | 335 ++++++++++++++++-- .../state_transition/src/metrics.rs | 35 ++ .../tests/beacon_spec/fork_choice.rs | 102 ++++++ crates/common/types/src/beacon/fork_choice.rs | 74 +++- crates/storage/src/store.rs | 111 +++++- docs/beacon_stf.md | 24 ++ docs/metrics.md | 19 + 7 files changed, 647 insertions(+), 53 deletions(-) diff --git a/crates/blockchain/state_transition/src/beacon/fork_choice.rs b/crates/blockchain/state_transition/src/beacon/fork_choice.rs index 79733afc5..73d285afd 100644 --- a/crates/blockchain/state_transition/src/beacon/fork_choice.rs +++ b/crates/blockchain/state_transition/src/beacon/fork_choice.rs @@ -173,13 +173,13 @@ 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, }; use crate::beacon::helpers::attestation as phase0_attestation; use crate::beacon::helpers::electra as electra_helpers; @@ -196,6 +196,7 @@ use crate::beacon::primitives::{ ValidatorIndex, }; use crate::beacon::stf; +use crate::metrics; // --------------------------------------------------------------------------- // LatestMessage, PowBlock, PayloadStatusV1, ForkChoiceNode, PayloadStatus @@ -210,8 +211,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, }; // --------------------------------------------------------------------------- @@ -1246,8 +1247,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 +1264,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 +1313,60 @@ 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, built on first +/// use after the checkpoint moves and shared until it moves again. +/// +/// 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. The only source is +/// [`checkpoint_state`], the state `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.set_justified_balances(Arc::clone(&balances)); + Ok(balances) +} + +/// One in-order pass over `state`'s registry: each validator's vote weight +/// (zero if inactive or slashed) and, in the same pass, their sum, 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 balances = state + .iter_validators() + .map(|validator| { + let balance = vote_balance(validator, epoch); + total = total.saturating_add(balance); + balance + }) + .collect(); + JustifiedBalances::new( + checkpoint, + balances, + total.max(preset::EFFECTIVE_BALANCE_INCREMENT), + ) } /// The effective balance of every non-equivocating, active, unslashed @@ -1412,8 +1496,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 +1505,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 +1545,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(); @@ -1950,7 +2030,10 @@ pub fn compute_node_weights( 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 reads each voter's weight from the snapshot of this same + // checkpoint state rather than descending the registry per vote; `state` + // stays for the boost gate, which wants the committees' state itself. + 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 +2043,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 { @@ -2502,7 +2584,7 @@ fn is_head_weak_with( attestation_score: Gwei, ) -> Result { let reorg_threshold = - calculate_committee_fraction(justified_state, config.reorg_head_weight_threshold)?; + calculate_committee_fraction(justified_state, config.reorg_head_weight_threshold); let mut head_weight = attestation_score; let head_state = store @@ -2535,7 +2617,7 @@ pub fn is_parent_strong(store: &Store, root: Root, config: &Config) -> Result (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 +5625,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 +5643,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 +5713,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 +5739,143 @@ 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; + 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); + assert_eq!(rebuilt.get(1), 0, "slashed in the new state"); + assert_eq!(rebuilt.get(2), preset::MAX_EFFECTIVE_BALANCE); } /// The failure a live mainnet follower hit: the beacon arm of diff --git a/crates/blockchain/state_transition/src/metrics.rs b/crates/blockchain/state_transition/src/metrics.rs index c9e1b8c7e..dc2b4c9f1 100644 --- a/crates/blockchain/state_transition/src/metrics.rs +++ b/crates/blockchain/state_transition/src/metrics.rs @@ -80,6 +80,41 @@ 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_STATE_TRANSITION_TIME_SECONDS: LazyLock = LazyLock::new(|| { register_histogram!( "lean_state_transition_time_seconds", 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/types/src/beacon/fork_choice.rs b/crates/common/types/src/beacon/fork_choice.rs index c39486a2a..821641108 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,81 @@ 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, +} + +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) -> Self { + Self { + checkpoint, + balances, + total_balance, + } + } + + /// 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); + 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); + } + #[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/storage/src/store.rs b/crates/storage/src/store.rs index 734439904..3aef4235d 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -29,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, @@ -673,6 +673,12 @@ 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; + /// 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 @@ -737,7 +743,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>, + /// The justified checkpoint state's 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: Option>, pub(crate) pow_blocks: HashMap, /// In-memory half of `Table::BeaconUnrealizedJustifications`; see this /// struct's own documentation for why this map, unlike its neighbours, @@ -4149,17 +4162,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 @@ -4206,7 +4230,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 @@ -4215,13 +4240,46 @@ 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 justified-balances snapshot, if it was built for exactly + /// `checkpoint`. + /// + /// The checkpoint is the whole key, so a caller never has to know which + /// code moved the justified checkpoint: a stale snapshot just misses. + pub fn justified_balances( + &self, + checkpoint: &BeaconCheckpoint, + ) -> Option> { + self.beacon + .lock() + .unwrap() + .justified_balances + .as_ref() + .filter(|balances| balances.checkpoint() == *checkpoint) + .cloned() + } + + /// Replaces the cached justified-balances snapshot. + /// + /// Takes `&self`, like [`Self::cache_state`]: the read-only fork-choice + /// helpers fill it on a miss. + pub fn set_justified_balances(&self, balances: Arc) { + self.beacon.lock().unwrap().justified_balances = Some(balances); + } + /// 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 { @@ -8692,6 +8750,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(); diff --git a/docs/beacon_stf.md b/docs/beacon_stf.md index 541317f36..0752eebff 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 diff --git a/docs/metrics.md b/docs/metrics.md index 87ca59168..f96969b4e 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -483,6 +483,25 @@ 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 Pubkey Cache Every BLS signature check `ethlambda beacon` runs (block import, fork choice, From 59f787394968371f340d5d205dbb56addbaa365a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:23:03 -0300 Subject: [PATCH 4/6] perf(beacon): precompute the epoch transition off the import path (#652) ## Motivation The first block of every epoch runs `process_epoch` inline on the chain actor (`fork_choice::on_block`, then `stf::state_transition`, then `process_slots`), followed by the post-epoch rehash the state-root check needs. In the import replay such blocks take ~1.5 s against ~0.58 s for an ordinary block (measured on the replay). The transition depends only on the parent, which is known by the end of the previous epoch's last slot, so it can run ahead of time on a blocking worker, off the import path. ## Design | Part | Behaviour | |---|---| | 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. The once-per-slot tick arms a delayed self-message on last slots only (`send_after`). Starts a worker only if none is running and the key is not cached. Covers a skipped last slot, where the head is an earlier block. | | Gate | Beacon only. Skipped while the sync tracker says syncing, and for a head more than one slot behind the wall clock (the tracker updates once per slot, so a follower replaying history would otherwise precompute every epoch). | | Work | `spawn_blocking`: clone the head state, `process_slots` to the first slot of `E+1`, flush pending writes, `hash_tree_root` so tree-node hashes are memoized, then message the state back. At most one worker in flight; deduped by `(epoch, root)` through the cache. | | Store | `CacheKey::CheckpointState { epoch: E+1, root: head_root }`. `checkpoint_state` derives exactly that value for the checkpoint (the checkpoint block's post-state advanced to the epoch's first slot), so attestation targets for `E+1` hit it too. A unit test asserts equal `hash_tree_root`. | | Consume | `on_block`: when the parent's slot is before the block epoch's first slot and the block is at or after it, look up `CheckpointState { block_epoch, parent_root }`. A hit clones the entry, advances any remaining skipped slots, and applies the block with the new `stf::apply_block` (the part of `state_transition` after `process_slots`; `process_slots` rejects a target equal to the current slot, so a boundary block cannot go through `state_transition`). | | Miss / race | Today's path, unchanged. A block that arrives while the worker runs imports inline; the late result is stored and unused by that block. | Forks: `process_slots` performs the fork upgrade at the boundary, so nothing here is fork-specific. New metrics (documented in `docs/metrics.md`): `lean_beacon_epoch_precompute_lookups_total{result}` (epoch-crossing imports only), `lean_beacon_epoch_precompute_seconds`, `lean_beacon_epoch_precompute_started_total{trigger}`. ## Not in this PR - Progressive balances (O(1) justification, and a cheaper `compute_pulled_up_tip`). - Holding the block when it races the worker (it computes inline instead, so no ordering dependency is added). - Advancing the head every slot; only epoch boundaries are advanced. ## Testing - `ethlambda-storage` lib, `ethlambda-state-transition` lib, `ethlambda-blockchain` lib, beacon spec suite (mainnet), clippy and `cargo check -p ethlambda`: see counts below. - New unit tests: resuming from a precomputed state gives the inline post-state (block on the boundary and with 1 and 3 skipped slots, compared with the block's committed state root); the lookup applies only to epoch-crossing imports; the worker's state equals `checkpoint_state`'s for the same key; trigger decisions as a pure function (head, timer, syncing, stale head, in-flight, already cached) and the three-quarter delay. - There is no actor-level test: the beacon actor's test harness drives handlers without a `Context`, so the trigger decision is tested as a pure function instead. - Not measured: no benchmark was run for this PR. Results: storage lib 138 passed (1 ignored); state-transition lib 327 passed (16 ignored); blockchain lib 217 passed; beacon spec suite (mainnet) 5734 passed, 4 failed (the 4 expected gossip matched_fixture_cases on the empty gossip dir), 152 ignored; clippy (workspace, and state-transition with beacon-spec-tests) clean; cargo check -p ethlambda clean. Co-authored-by: Pablo Deymonnaz --- crates/blockchain/src/epoch_precompute.rs | 369 +++++++++++++++++ crates/blockchain/src/lib.rs | 18 + crates/blockchain/src/sync_status.rs | 6 + .../src/beacon/fork_choice.rs | 370 +++++++++++++++++- .../state_transition/src/beacon/stf/mod.rs | 28 ++ .../state_transition/src/metrics.rs | 49 ++- docs/beacon_stf.md | 29 ++ docs/metrics.md | 20 + 8 files changed, 884 insertions(+), 5 deletions(-) create mode 100644 crates/blockchain/src/epoch_precompute.rs 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 c9dc378df..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), @@ -3895,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); } } @@ -4439,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/fork_choice.rs b/crates/blockchain/state_transition/src/beacon/fork_choice.rs index 73d285afd..f99165f0a 100644 --- a/crates/blockchain/state_transition/src/beacon/fork_choice.rs +++ b/crates/blockchain/state_transition/src/beacon/fork_choice.rs @@ -179,7 +179,8 @@ 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, + 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; @@ -1004,6 +1005,117 @@ 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. +#[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 Some(precomputed) = + precomputed_epoch_state(store, parent_state.slot(), parent_root, block_slot) + else { + let mut state = parent_state.clone(); + let outcome = stf::state_transition( + &mut state, + signed_block, + validate_result, + config, + engine, + committees, + active_balances, + ); + return (state, outcome); + }; + + 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 // --------------------------------------------------------------------------- @@ -4578,7 +4690,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`. @@ -4698,11 +4809,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, @@ -9824,4 +9946,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/stf/mod.rs b/crates/blockchain/state_transition/src/beacon/stf/mod.rs index 8a57ca638..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 diff --git a/crates/blockchain/state_transition/src/metrics.rs b/crates/blockchain/state_transition/src/metrics.rs index dc2b4c9f1..63c029166 100644 --- a/crates/blockchain/state_transition/src/metrics.rs +++ b/crates/blockchain/state_transition/src/metrics.rs @@ -80,7 +80,6 @@ pub fn inc_total_active_balance_lookups(result: &str) { .inc(); } - static LEAN_BEACON_JUSTIFIED_BALANCES_LOOKUPS_TOTAL: LazyLock = LazyLock::new( || { register_int_counter_vec!( @@ -115,6 +114,54 @@ 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/docs/beacon_stf.md b/docs/beacon_stf.md index 0752eebff..b2df5a0f2 100644 --- a/docs/beacon_stf.md +++ b/docs/beacon_stf.md @@ -617,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/metrics.md b/docs/metrics.md index f96969b4e..a68219212 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -502,6 +502,26 @@ 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, From 6cf0310f8d1638ce5801b3f32f43b43eb0c30efd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:23:03 -0300 Subject: [PATCH 5/6] feat(beacon): score gossipsub peers with lighthouse's parameters (#656) ## Motivation The beacon wire had no gossipsub peer scoring (`// TODO: set peer scoring params`). Gossipsub kept exchanging with every peer, including ones that send invalid messages, break IWANT promises or cannot keep up, and the connection cap was the only bound on bad peers. Slow peers are the visible case on the followers. The mainnet follower logged 96k gossipsub `Send Queue full` warnings in 22 h, from only 8 peers (one of them 60k). The Hoodi follower currently logs about 5000 a minute, almost all from 2 peers. Without scoring, nothing penalizes or drops those peers. ## Changes | Area | Change | |---|---| | `p2p/src/beacon/scoring.rs` (new) | Lighthouse v8.2.2's `gossipsub_scoring_parameters.rs`, ported formula for formula: thresholds, peer score parameters, and per-topic parameters for `beacon_block`, `beacon_aggregate_and_proof`, all 64 `beacon_attestation_{n}` subnets, exits and slashings. `MeshDeliveryGate` (the P3 sync gate below). `DigestRole` and `PeerScoring::hold`, which score every held fork digest by its role (below). Unit tests for the derived parameters against lighthouse's values. | | `p2p/src/lib.rs` | Scoring is enabled on the beacon wire only (lean is unchanged). The p2p actor rebuilds the validator-count-dependent topic parameters every slot (`RefreshPeerScoring`, first run at startup) from the head's current-epoch shuffling, under every held fork digest, and sends them to the swarm. | | `p2p/src/beacon/transition.rs` | `apply` hands the swarm every held digest's parameters before it subscribes to any of them. | | `p2p/src/swarm_adapter.rs` | `SwarmCommand::SetTopicScoreParams`. On the existing 10 s tick, peers below the graylist are disconnected and peers are counted by score band. | | `storage/src/committee_cache.rs`, `types/beacon/committees.rs` | `CommitteeCache::head_current_committees` reads the pinned head's current-epoch shuffling (never builds one), and `EpochCommittees::active_validator_count` takes the count from its length, so the refresh needs no registry scan. | | Metrics | `lean_gossipsub_peers_by_score{band}`, `lean_gossipsub_score_disconnects_total`, `lean_gossipsub_mesh_delivery_scoring`. | | Docs | `beacon_wire.md` "Peer scoring" section; `metrics.md`. | ## Design decisions - **Lighthouse's parameters.** Teku, Lodestar and Grandine run the same ones, so a score here means what it means on most of mainnet. Thresholds: gossip -4000, publish -8000, graylist -16000. The slow-peer penalty (-10 per dropped message) is kept, since slow peers are what the followers see most. - **`mesh_n` is ours (8), not lighthouse's 5.** It only enters the first-message-delivery cap. - **P3 (mesh deliveries) is gated on our own sync.** Lighthouse subscribes to these topics only once synced. This node subscribes at startup and, while catching up, ignores every aggregate voting for a block it has not imported. A mesh peer credited with no aggregates scores about -19.5k on aggregates alone (below the graylist), so a restart would graylist our whole aggregate mesh for our own lag. P3 is off while the head lags the wall clock by more than 4 slots, and back on only after one epoch within that lag, so the counters have refilled. While off, its threshold and weight are zero but its cap is not, so the counters keep counting to their real ceiling. - **The gate reads head lag directly, not `SyncStatus`.** On the mainnet follower `SyncStatus` reported `synced` through a 10-minute catch-up up to 98 slots behind: its network-stall rule reads a lagging freshest-imported block as a stalled network. - **Graylisted peers are disconnected; no ban list.** Gossipsub alone only ignores a graylisted peer's RPCs, and the peer keeps a connection slot. A peer's negative score is retained for `retain_score` (100 epochs) after it leaves, so one that reconnects comes back graylisted and is dropped again on the next pass. - **Columns, sync committee contributions and BLS changes stay unscored.** This node `Ignore`s every contribution and change (no consumer), and a delivery is only credited once accepted, so P3 there would penalize the mesh for a gap that is ours. Only Prysm scores columns. - **All 64 attestation subnets get parameters**, not just the backbone ones, because aggregator duties join others at runtime. - **Every held fork digest is scored, by role.** Around a boundary the node holds two digests' topics, and each gets the whole parameter set (exits and slashings included): | Digest | Topic weights | P3 | |---|---|---| | upcoming, joined `SUBSCRIBE_LEAD_EPOCHS` ahead | full | off: nothing is published under it yet | | current | full | per the gate above | | previous, held `UNSUBSCRIBE_LAG_EPOCHS` after | 0 | off | - The parameters go out before the subscriptions, on the swarm's one 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's traffic moves away 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 (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`). The cost, as on lighthouse: invalid messages on the previous digest's topics stop costing P4 while it is held. - A switch restarts the P3 warmup. The new digest's counters start empty, while its mesh, formed before the boundary, can already be past the deliveries' activation (one epoch for blocks and aggregates, the same as the subscription lead). Lighthouse joins two slots ahead, so activation alone covers it there. ## Not in this PR - Fixing `SyncStatus` during beacon catch-up. ## Testing Run on the branch after merging `beacon-chain-integration` (125dce1c). | Command | Result | |---|---| | `cargo clippy --workspace --all-targets --profile release-fast -- -D warnings` | clean | | `cargo fmt --all --check` | clean | | `cargo test --profile release-fast -p ethlambda-p2p --lib` | 218 passed, 1 ignored | | `cargo test --profile release-fast -p ethlambda-storage --lib` | 139 passed, 1 ignored | After the fork-rollover fix (dadac7a7), on the branch merged with `main`: | Command | Result | |---|---| | `make lint` (`cargo clippy --locked --workspace --all-targets -- -D warnings`) | clean | | `cargo fmt --all --check` | clean | | `cargo test --profile release-fast -p ethlambda-p2p --lib` | 360 passed, 1 ignored | The rollover tests in `beacon::scoring`: `a_rollover_parametrizes_each_digest_before_joining_it` runs the real `transition::apply` across a boundary with the swarm's commands captured in order; `a_mesh_grafted_ahead_of_its_fork_is_scored` connects two real swarms and checks the mesh grafted before the fork earns a score, against a control topic joined before its parameters that stays at 0; `a_previous_digest_never_graylists_its_silent_mesh` is a tick-by-tick model of gossipsub's mesh-delivery arithmetic (a live run would need 53 slots of decay). The first two fail if the parameters are sent after the subscriptions, and the first fails if the switch skips the warmup restart. The beacon spec suites were not run: the only state-transition change makes `committee_count_per_slot` public, and fork choice is untouched. Co-authored-by: Pablo Deymonnaz --- CLAUDE.md | 1 + .../src/beacon/helpers/accessors.rs | 6 +- crates/common/types/src/beacon/committees.rs | 7 + crates/net/p2p/src/beacon/mod.rs | 1 + crates/net/p2p/src/beacon/scoring.rs | 1474 +++++++++++++++++ crates/net/p2p/src/beacon/swarm.rs | 11 +- crates/net/p2p/src/beacon/transition.rs | 24 +- crates/net/p2p/src/gossipsub/handler.rs | 2 +- crates/net/p2p/src/gossipsub/mod.rs | 1 + crates/net/p2p/src/lib.rs | 77 +- crates/net/p2p/src/metrics.rs | 45 + crates/net/p2p/src/req_resp/handlers.rs | 1 + crates/net/p2p/src/swarm_adapter.rs | 77 +- crates/storage/src/committee_cache.rs | 42 + docs/beacon_wire.md | 86 + docs/metrics.md | 20 + 16 files changed, 1848 insertions(+), 27 deletions(-) create mode 100644 crates/net/p2p/src/beacon/scoring.rs diff --git a/CLAUDE.md b/CLAUDE.md index 35591b236..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 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/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/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/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/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/metrics.md b/docs/metrics.md index a68219212..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 From 512ff767b544663388a32cc4b9c323cd02e09892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:23:04 -0300 Subject: [PATCH 6/6] perf(beacon): build justified balances at import, weigh the gloas boost from the snapshot (#665) ## Motivation On the eth-3 followers (48h of import timings, 10-03 to 10-05), the head recompute at an epoch-start tick sometimes took 0.5-4 s, and the epoch's first block queued behind it: | network | head computes > 0.5 s | slot-0 block queue wait | |---|---|---| | Hoodi | 8 (7 in 2-4 s, 1 in 4-12 s) | up to 2.3 s | | Plataberget | 22 | up to 1.1 s | At that tick the justified checkpoint moves to (E-1, R), and the first head walk builds the justified-balances snapshot from `checkpoint_state((E-1, R))`. When that state has left the 32-entry state cache, the chain actor rebuilds it inline: a full snapshot decode (~0.3 s on Hoodi), plus a whole epoch transition when R's block is before the epoch's first slot (an empty first slot, or an epoch-start block orphaned by a late block). A replay of the logs through a model of the state cache puts the rebuilds at ~100 of ~410 Hoodi epoch ticks. Lighthouse never reads a state at that tick: `BeaconForkChoiceStore::on_verified_block` builds the justified balances at block import, for the block's epoch-boundary checkpoint, into a small cache keyed by (boundary root, epoch), from a state import already has. The tick only looks them up. Prysm keeps a per-checkpoint balances vector too, but builds it from the checkpoint block's un-advanced post-state, which is not what the specification's `checkpoint_states` holds; this PR follows Lighthouse. ## Changes This branch carries two open PRs it builds on, merged in with the resolutions `tmp/bci-...-gloas-live` already uses for gloas (minus #648's active-balance cache and #646's block-production operations, which are not on this branch): - #649, the justified-balances snapshot (`86b07388`) - #652, the epoch precompute and its `transition_block` (`b3017e4b`) The change itself is `6badb4f3`: 1. **A balances cache instead of one snapshot.** The store keeps up to 8 `JustifiedBalances` keyed by checkpoint. When full it evicts the lowest epoch first (ties: oldest insert), and never the current justified checkpoint. Not Lighthouse's plain 4-entry FIFO: on a network with many forks (Plataberget), several blocks crossing one boundary from different parents could flush the canonical entry within an epoch. 2. **Built at import.** - A block at its epoch's first slot caches the balances from its own post-state, under (epoch, block_root): that post-state is what `checkpoint_state` returns for it. - A block crossing from an earlier-epoch parent (empty first slot) caches them from the boundary state, under (epoch, parent_root), inside `transition_block`: from the precomputed state on a precompute hit, or by advancing the cloned parent to the epoch start once on a miss (each slot is still processed once). The boundary state depends only on the parent, so building it before the block is validated cannot cache a wrong value. - `justified_balances()` looks the current checkpoint up and keeps the lazy `checkpoint_state` path for a miss (a restart, or a boundary this node never imported). 3. **The gloas boost reads the snapshot.** `compute_node_weights` fetched the justified checkpoint state on every head walk, only for the gloas boost gate. `is_head_weak`, `is_parent_strong` and `should_apply_proposer_boost` now use the snapshot: the total active balance and vote weights were already there; the equivocator term in `is_head_weak` needs the raw `effective_balance` at the justified state, which the vote weights zero for slashed validators, so the snapshot gains an exact copy as a `u16` count of `EFFECTIVE_BALANCE_INCREMENT` per validator (asserted at build). Committees still come from the head/parent block states. The spec-literal `get_weight`, `gloas_get_weight` and `get_attestation_score` stay state-based as references. On a snapshot hit, no head walk (pre-gloas or gloas) reads the justified state. From review: 4. **The committee total leaves slashed validators out.** The snapshot's total, and the spec-literal `calculate_committee_fraction`, now sum active, unslashed validators only, as consensus-specs #5679 does. Fork choice weighs no vote from a slashed validator, so a boost or reorg threshold sized against their stake outweighs a larger share of the votes that count. #5679 first shipped in v1.7.0-beta.4 and the fixtures are pinned to beta.2, so `docs/spec_deviations.md` records it until the pin catches up; no beta.2 fork-choice fixture tells the two apart. Lighthouse already sums the same way. The raw `effective_balance` copy is unchanged, since `is_head_weak` still needs it for slashed equivocators. 5. **The timer precompute is bounded to a head in its own epoch.** The timer trigger checked only that the head was not past its slot. In a network-wide stall the sync tracker reports synced, so a timer epochs past a stalled head replayed every epoch in between, and again under a new key at each later epoch. A worker now crosses one epoch boundary at most, on either trigger. ## Cost - One in-order registry pass (~25 ms on Hoodi, `lean_beacon_justified_balances_build_seconds`) per epoch-boundary checkpoint, at import, on the importer, as in Lighthouse. - 10 bytes per validator per cached snapshot (8 for the vote weight, 2 for the new raw effective balance): ~12 MB per entry on Hoodi, so ~96 MB with all 8 entries filled. Worth reviewing whether 8 entries (vs 4) earn their memory. ## Testing - New: the cache's eviction policy (bound, lowest epoch, ties, protected justified, replace); a crossing import caches the boundary balances on both precompute paths with the block's state root still verifying; a first-slot import caches from its post-state and other slots cache nothing; a snapshot hit reads no justified state on every head path under both fork rules; a slashed equivocator still counts toward head weakness, matching the state-based reference; a slashed-but-active validator is left out of the proposer score and the snapshot total but keeps its raw balance; a timer whose epoch holds no block, or a stall several epochs long, starts no precompute. - `cargo test --profile release-fast --lib --bins` for storage, state-transition, blockchain and types: 1083 passed, 0 failed. - Beacon `fork_choice` fixtures, consensus-specs v1.7.0-beta.2, mainnet: 206 passed, 0 failed (before items 4 and 5; not re-run locally since, so CI's `test-beacon` jobs are the check). - `cargo clippy --workspace --all-targets -- -D warnings`: clean. ## Not covered - Live effect on the epoch-tick head recompute: worth re-measuring on a follower after deploy. - An `on_block` unit test of the first-slot hook needs a BLS-signed block; it is tested through `transition_block` and the cache helper instead. --------- Co-authored-by: Pablo Deymonnaz --- .../src/beacon/fork_choice.rs | 438 ++++++++++++++++-- crates/common/types/src/beacon/fork_choice.rs | 41 +- crates/storage/src/store.rs | 172 ++++++- docs/spec_deviations.md | 25 + 4 files changed, 614 insertions(+), 62 deletions(-) diff --git a/crates/blockchain/state_transition/src/beacon/fork_choice.rs b/crates/blockchain/state_transition/src/beacon/fork_choice.rs index f99165f0a..068cd60a7 100644 --- a/crates/blockchain/state_transition/src/beacon/fork_choice.rs +++ b/crates/blockchain/state_transition/src/beacon/fork_choice.rs @@ -1028,7 +1028,6 @@ pub fn advance_to_epoch_start( 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. @@ -1038,6 +1037,17 @@ pub fn advance_to_epoch_start( /// 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, @@ -1051,22 +1061,55 @@ fn transition_block( 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(); - let outcome = stf::state_transition( - &mut state, - signed_block, - validate_result, - config, - engine, - committees, - active_balances, - ); + 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. @@ -1432,17 +1475,23 @@ pub fn get_proposer_score(store: &Store, config: &Config) -> Result { )) } -/// The flat balances of the store's justified checkpoint state, built on first -/// use after the checkpoint moves and shared until it moves again. +/// 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. The only source is -/// [`checkpoint_state`], the state `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. +/// 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) { @@ -1452,23 +1501,63 @@ pub fn justified_balances(store: &Store, config: &Config) -> Result 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 @@ -1478,6 +1567,7 @@ fn build_justified_balances(checkpoint: Checkpoint, state: &BeaconState) -> Just checkpoint, balances, total.max(preset::EFFECTIVE_BALANCE_INCREMENT), + increments.into(), ) } @@ -1538,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. /// @@ -2140,11 +2262,10 @@ 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)?; - // The vote loop reads each voter's weight from the snapshot of this same - // checkpoint state rather than descending the registry per vote; `state` - // stays for the boost gate, which wants the committees' state itself. + // 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); @@ -2257,7 +2378,7 @@ pub fn compute_node_weights( config, committees, index, - &state, + &balances, |parent| { Ok(weights.raw(ForkChoiceNode { root: parent, @@ -2658,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"))?; @@ -2671,7 +2791,7 @@ pub fn is_head_weak( head_slot, config, committees, - &justified_state, + &balances, attestation_score, ) } @@ -2692,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 @@ -2708,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)); } } } @@ -2726,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) } @@ -3510,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), ) } @@ -3540,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(); @@ -3569,7 +3692,7 @@ fn should_apply_proposer_boost_with( parent_slot, config, committees, - justified_state, + balances, parent_attestation_score, )? { return Ok(true); @@ -4875,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`. @@ -5978,7 +6106,7 @@ mod tests { state .validator_mut(0) .expect("registered") - .effective_balance = 5; + .effective_balance = 5 * preset::EFFECTIVE_BALANCE_INCREMENT; state.validator_mut(1).expect("registered").slashed = true; state.apply_pending_mutations(); store.cache_state( @@ -5995,11 +6123,223 @@ mod tests { 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); + 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 /// `Store::update_checkpoints` prunes the block index below the finalized /// block's own slot, and any validator whose freshest vote was for a block diff --git a/crates/common/types/src/beacon/fork_choice.rs b/crates/common/types/src/beacon/fork_choice.rs index 821641108..d5318e2e1 100644 --- a/crates/common/types/src/beacon/fork_choice.rs +++ b/crates/common/types/src/beacon/fork_choice.rs @@ -232,18 +232,46 @@ pub struct JustifiedBalances { /// 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) -> Self { + 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 @@ -275,13 +303,22 @@ mod tests { #[test] fn justified_balances_read_zero_past_the_registry() { - let balances = JustifiedBalances::new(Checkpoint::default(), vec![7, 0, 9].into(), 16); + 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] diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 3aef4235d..ebf3f4e17 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -679,6 +679,67 @@ impl GossipSignatureBuffer { /// 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 @@ -747,10 +808,10 @@ pub(crate) struct BeaconScratch { /// 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>, - /// The justified checkpoint state's balances, flattened for the vote loop + /// 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: Option>, + 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, @@ -4254,11 +4315,10 @@ impl Store { } } - /// The cached justified-balances snapshot, if it was built for exactly - /// `checkpoint`. + /// 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: a stale snapshot just misses. + /// code moved the justified checkpoint: an unknown checkpoint just misses. pub fn justified_balances( &self, checkpoint: &BeaconCheckpoint, @@ -4267,17 +4327,22 @@ impl Store { .lock() .unwrap() .justified_balances - .as_ref() - .filter(|balances| balances.checkpoint() == *checkpoint) - .cloned() + .get(checkpoint) } - /// Replaces the cached justified-balances snapshot. + /// 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 set_justified_balances(&self, balances: Arc) { - self.beacon.lock().unwrap().justified_balances = Some(balances); + 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 @@ -10076,4 +10141,89 @@ mod tests { 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/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