From 96adc827f5c16b753cea9df1835f33b090813c10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:49:49 -0300 Subject: [PATCH 1/2] feat(beacon): fast confirmation rule, its event and the engine safe hash contributoor subscribes to the Beacon API `fast_confirmation` event, which this node accepted but never emitted for lack of a Fast Confirmation Rule. `forkchoiceUpdated` had the same gap: the spec's `get_safe_execution_block_hash` is built on the rule, so the safe block was a stand-in (the justified checkpoint's payload). Implement the rule from consensus-specs `master` at f993cf2 (not the pinned beta.2, which ships no FCR fixtures and lacks the gloas empty-slot discount, precise committee weight, the restart-resilient root and the `support_discount <= maximum_support` assertion), one Rust function per spec function in `beacon::fast_confirmation`. The beacon actor runs it once per slot at the tick, after the head recompute and before `forkchoiceUpdated`, and skips it (resetting its variables) while the head is more than 32 slots behind the clock. Each run publishes a `fast_confirmation` event and its confirmed block becomes the engine's safe hash, which removes the documented spec deviation. The spec's helpers walk the registry or the committees per block, which is tens of passes per slot at mainnet size, so a run flattens each balance source once, folds every block's attestation score from the latest messages in one pass and sums cached per-slot committee weights; only epoch-crossing and empty-slot ranges deduplicate validators. Tests check those sums against the spec's set-based definitions and `get_attestation_score`. --- CLAUDE.md | 11 +- crates/blockchain/src/beacon_payloads.rs | 179 +- crates/blockchain/src/events.rs | 69 + crates/blockchain/src/import_timing.rs | 3 + crates/blockchain/src/lib.rs | 54 +- crates/blockchain/src/metrics.rs | 24 + .../src/beacon/fast_confirmation.rs | 2734 +++++++++++++++++ .../state_transition/src/beacon/mod.rs | 1 + crates/common/types/src/beacon/fork_choice.rs | 40 + crates/storage/src/store.rs | 91 +- docs/beacon_engine.md | 24 +- docs/beacon_stf.md | 36 + docs/rpc.md | 5 +- docs/spec_deviations.md | 11 +- 14 files changed, 3254 insertions(+), 28 deletions(-) create mode 100644 crates/blockchain/state_transition/src/beacon/fast_confirmation.rs diff --git a/CLAUDE.md b/CLAUDE.md index a171704a8..25721d565 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -824,8 +824,9 @@ transitions are in `ethlambda-types`, per the section above. Nothing above payload via the execution-chain walk; an engine error keeps the envelope and retries once a slot, probing with the oldest), and a gloas head sends `engine_forkchoiceUpdatedV4` with the custody columns (head hash by the head - node's FULL or EMPTY status, finalized and safe from the checkpoint blocks' - `bid.parent_block_hash`, safe being a fallback for fast confirmation). Building + node's FULL or EMPTY status, finalized from the finalized block's and safe + from the Fast Confirmation Rule's confirmed block's `bid.parent_block_hash`). + Building a gloas payload for `produceBlockV4` uses `PayloadAttributesV4` (V3's fields plus `slot_number` and `target_gas_limit`, the parent bid's gas limit) through `forkchoice_updated_v4_with_attributes` and `get_payload_v6` @@ -859,6 +860,12 @@ transitions are in `ethlambda-types`, per the section above. Nothing above [`docs/beacon_stf.md`](docs/beacon_stf.md) has the design and [`docs/spec_deviations.md`](docs/spec_deviations.md) the deliberate departures from the spec, in particular the fulu-to-gloas fork-choice boundary rule. +- **Fast Confirmation Rule** (`beacon/fast_confirmation.rs`, consensus-specs + `master` @ `f993cf2`, not the pinned release): run once per slot by the beacon + actor's tick after the head recompute and before `forkchoiceUpdated`; its + confirmed root is the engine's safe hash and feeds the `fast_confirmation` + event. State lives in `Store` (`fast_confirmation`), per-run sums are shared + in a `RunContext`. See [`docs/beacon_stf.md`](docs/beacon_stf.md#fast-confirmation-rule). - Nothing is ignored for being unimplemented in the state transition or fork choice. Ignored cases are the `LightClient*` containers (a different layer, out of scope), `networking/gossip_*` cases outside what the node validates diff --git a/crates/blockchain/src/beacon_payloads.rs b/crates/blockchain/src/beacon_payloads.rs index a935fb240..1d87e9242 100644 --- a/crates/blockchain/src/beacon_payloads.rs +++ b/crates/blockchain/src/beacon_payloads.rs @@ -91,6 +91,28 @@ pub fn checkpoint_hash(store: &Store, root: H256) -> ExecutionBlockHash { store.beacon_el_block_hash(root).unwrap_or(H256::ZERO) } +/// `get_safe_execution_block_hash` (bellatrix `fast-confirmation.md`, modified +/// by gloas): the execution block hash of the most recent block the Fast +/// Confirmation Rule confirmed, which `forkchoiceUpdated` carries as the safe +/// block. +/// +/// Bellatrix through fulu name the confirmed block's own payload hash; gloas +/// names its `bid.parent_block_hash`, since only the parent payload of a +/// confirmed beacon block is safe. [`checkpoint_hash`] is exactly that pair, +/// including for a pre-gloas block under a gloas head, and it answers zero for +/// a block with no known payload (a pre-bellatrix block), the way the +/// finalized hash does. +/// +/// The rule has not run yet (or was reset while the node is syncing) when the +/// store holds no confirmed root, and then its own initialization applies: the +/// finalized block. +pub(crate) fn get_safe_execution_block_hash(store: &Store) -> ExecutionBlockHash { + let confirmed_root = store + .fast_confirmed_root() + .unwrap_or_else(|| store.beacon_finalized_checkpoint().root); + checkpoint_hash(store, confirmed_root) +} + /// What `forkchoiceUpdated` should carry for the current head, or `None` when /// there is nothing to say yet. pub(crate) struct ForkchoicePlan { @@ -109,11 +131,10 @@ pub(crate) struct ForkchoicePlan { /// hash is zero, as before. /// /// Finalized: the finalized block's `bid.parent_block_hash` (spec). Safe: the -/// spec's `get_safe_execution_block_hash` is fast-confirmation based, which -/// this follower does not run, so the justified checkpoint block's -/// `bid.parent_block_hash` stands in (see `docs/spec_deviations.md`). A -/// pre-gloas checkpoint block, including one under a gloas head, uses its own -/// payload hash. +/// spec's `get_safe_execution_block_hash`, the payload hash of the most recent +/// block the Fast Confirmation Rule confirmed ([`get_safe_execution_block_hash`]). +/// Before the rule's first run its confirmed root is the finalized block, as +/// its initialization sets it. pub(crate) fn forkchoice_plan(store: &Store, head_root: H256) -> Option { let head_block = store.get_signed_block(&head_root).ok().flatten(); let head_bid = head_block.as_ref().and_then(bid_of); @@ -129,7 +150,7 @@ pub(crate) fn forkchoice_plan(store: &Store, head_root: H256) -> Option SignedBeaconBlock { + SignedBeaconBlock::Gloas(gloas::SignedBeaconBlock { + message: gloas::BeaconBlock { + slot, + parent_root, + body: gloas::BeaconBlockBody { + signed_execution_payload_bid: gloas::SignedExecutionPayloadBid { + message: gloas::ExecutionPayloadBid { + parent_block_hash: parent_hash, + block_hash: hash, + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + signature: Default::default(), + }) + } + + /// `get_safe_execution_block_hash` before gloas: the confirmed block's own + /// payload hash, whatever the justified checkpoint says. + #[test] + fn safe_follows_the_confirmed_root_for_a_pre_gloas_head() { + let mut store = beacon_store(0, 0); + let (hash_1, hash_2) = (H256::repeat_byte(1), H256::repeat_byte(2)); + let block_1 = fulu_block(H256::ZERO, 1, 0); + let root_1 = block_1.message_hash_tree_root(); + let block_2 = fulu_block(root_1, 2, 0); + let root_2 = block_2.message_hash_tree_root(); + for (root, block, slot, hash) in + [(root_1, block_1, 1, hash_1), (root_2, block_2, 2, hash_2)] + { + store.insert_pending_block(root, block).expect("insert"); + store.insert_beacon_el_block_hash(root, slot, hash); + } + + // The rule has not run: its own initialization, the finalized block + // (here the unknown zero anchor, which has no payload). + let plan = forkchoice_plan(&store, root_2).expect("a plan"); + assert_eq!(plan.state.head_block_hash, hash_2); + assert_eq!(plan.state.safe_block_hash, plan.state.finalized_block_hash); + + // Once it has, safe follows the confirmed root. + confirm(&store, root_1); + let plan = forkchoice_plan(&store, root_2).expect("a plan"); + assert_eq!(plan.state.safe_block_hash, hash_1); + confirm(&store, root_2); + let plan = forkchoice_plan(&store, root_2).expect("a plan"); + assert_eq!(plan.state.safe_block_hash, hash_2); + // Finalized is the checkpoint's, untouched. + assert_eq!(plan.state.finalized_block_hash, H256::ZERO); + assert!(!plan.gloas); + } + + /// Gloas modifies it: only the parent payload of a confirmed beacon block + /// is safe, so the bid's `parent_block_hash`. + #[test] + fn safe_is_the_confirmed_blocks_parent_payload_for_a_gloas_head() { + let mut store = beacon_store(0, 0); + let (parent_hash, hash_1, hash_2) = ( + H256::repeat_byte(0x10), + H256::repeat_byte(0x11), + H256::repeat_byte(0x12), + ); + let block_1 = gloas_block(1, H256::ZERO, parent_hash, hash_1); + let root_1 = block_1.message_hash_tree_root(); + let block_2 = gloas_block(2, root_1, hash_1, hash_2); + let root_2 = block_2.message_hash_tree_root(); + store.insert_pending_block(root_1, block_1).expect("insert"); + store.insert_pending_block(root_2, block_2).expect("insert"); + make_head(&mut store, root_2, PayloadStatus::Full); + + confirm(&store, root_2); + let plan = forkchoice_plan(&store, root_2).expect("a plan"); + assert!(plan.gloas); + assert_eq!(plan.state.head_block_hash, hash_2, "a FULL head"); + assert_eq!( + plan.state.safe_block_hash, hash_1, + "the confirmed bid's parent" + ); + + confirm(&store, root_1); + let plan = forkchoice_plan(&store, root_2).expect("a plan"); + assert_eq!(plan.state.safe_block_hash, parent_hash); + } + + /// A pre-gloas confirmed block under a gloas head keeps its own payload + /// hash, as the finalized hash does for a pre-gloas finalized block. + #[test] + fn a_pre_gloas_confirmed_block_under_a_gloas_head_uses_its_own_hash() { + let mut store = beacon_store(0, 0); + let fulu_hash = H256::repeat_byte(0x20); + let fulu = fulu_block(H256::ZERO, 1, 0); + let fulu_root = fulu.message_hash_tree_root(); + store.insert_pending_block(fulu_root, fulu).expect("insert"); + store.insert_beacon_el_block_hash(fulu_root, 1, fulu_hash); + let gloas = gloas_block(2, fulu_root, fulu_hash, H256::repeat_byte(0x21)); + let gloas_root = gloas.message_hash_tree_root(); + store + .insert_pending_block(gloas_root, gloas) + .expect("insert"); + make_head(&mut store, gloas_root, PayloadStatus::Full); + + confirm(&store, fulu_root); + let plan = forkchoice_plan(&store, gloas_root).expect("a plan"); + assert_eq!(plan.state.safe_block_hash, fulu_hash); + // A confirmed block with no known payload is zero, like finalized. + confirm(&store, H256::repeat_byte(0x99)); + assert_eq!(get_safe_execution_block_hash(&store), H256::ZERO); + } +} diff --git a/crates/blockchain/src/events.rs b/crates/blockchain/src/events.rs index 1e82f89e8..ba925b1f7 100644 --- a/crates/blockchain/src/events.rs +++ b/crates/blockchain/src/events.rs @@ -85,6 +85,8 @@ pub enum Topic { ExecutionPayloadAvailable, ExecutionPayloadBid, PayloadAttestationMessage, + /// Beacon only, and one of the few on this side of the line that is + /// emitted: once per run of the Fast Confirmation Rule. FastConfirmation, ProposerPreferences, } @@ -309,6 +311,8 @@ pub enum ChainEvent { BeaconAttestation(Box), /// Beacon `data_column_sidecar`: a column this node custodies was stored. DataColumnSidecar(DataColumnSidecarEvent), + /// Beacon `fast_confirmation`: the Fast Confirmation Rule ran. + BeaconFastConfirmation(BeaconFastConfirmationEvent), } impl ChainEvent { @@ -325,6 +329,7 @@ impl ChainEvent { ChainEvent::Aggregate { .. } => Topic::Aggregate, ChainEvent::ChainReorg { .. } | ChainEvent::BeaconChainReorg(_) => Topic::ChainReorg, ChainEvent::DataColumnSidecar(_) => Topic::DataColumnSidecar, + ChainEvent::BeaconFastConfirmation(_) => Topic::FastConfirmation, } } } @@ -421,6 +426,20 @@ impl From<&SignedAggregateAndProof> for BeaconAttestationEvent { } } +/// The Beacon API `fast_confirmation` payload. +/// +/// Emitted every time the rule runs, whether or not the confirmed block +/// changed: `block` and `slot` are the most recent confirmed block's, and +/// `current_slot` is the wall-clock slot of the run. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct BeaconFastConfirmationEvent { + pub block: H256, + #[serde(serialize_with = "quoted_or_bare::serialize")] + pub slot: u64, + #[serde(serialize_with = "quoted_or_bare::serialize")] + pub current_slot: u64, +} + /// The Beacon API `data_column_sidecar` payload. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct DataColumnSidecarEvent { @@ -601,6 +620,17 @@ impl ChainEvents { self.bus.has_subscribers().then_some(previous) } + /// Beacon `fast_confirmation` for one run of the Fast Confirmation Rule. + pub(crate) fn publish_fast_confirmation(&self, block: H256, slot: u64, current_slot: u64) { + self.bus.emit(ChainEvent::BeaconFastConfirmation( + BeaconFastConfirmationEvent { + block, + slot, + current_slot, + }, + )); + } + /// `block`: `block` was imported at `slot`, in the store's chain's shape. pub(crate) fn publish_block(&self, store: &Store, slot: u64, block: H256) { let event = match store.chain() { @@ -1511,6 +1541,45 @@ mod tests { ); } + #[test] + fn beacon_fast_confirmation_matches_the_specification_example() { + let event = ChainEvent::BeaconFastConfirmation(BeaconFastConfirmationEvent { + block: root("0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2"), + slot: 1, + current_slot: 2, + }); + assert_matches_example( + event, + Topic::FastConfirmation, + r#"{"block": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", "slot": "1", "current_slot": "2"}"#, + ); + } + + #[test] + fn a_fast_confirmation_run_is_published_even_when_nothing_changed() { + let store = crate::tests::beacon_store(0, 0); + let events = ChainEvents::new(EventBus::default(), &store); + let mut receiver = events.subscribe(); + let block = H256::repeat_byte(5); + events.publish_fast_confirmation(block, 7, 9); + events.publish_fast_confirmation(block, 7, 10); + for current_slot in [9, 10] { + match receiver.try_recv().expect("an event per run") { + ChainEvent::BeaconFastConfirmation(event) => { + assert_eq!( + event, + BeaconFastConfirmationEvent { + block, + slot: 7, + current_slot + } + ); + } + other => panic!("unexpected event {other:?}"), + } + } + } + #[test] fn beacon_block_gossip_matches_the_specification_example() { let event = ChainEvent::BeaconBlockGossip(BeaconBlockGossipEvent { diff --git a/crates/blockchain/src/import_timing.rs b/crates/blockchain/src/import_timing.rs index 6625a472f..91f2554ca 100644 --- a/crates/blockchain/src/import_timing.rs +++ b/crates/blockchain/src/import_timing.rs @@ -603,6 +603,9 @@ pub struct CascadeTimings { pub struct HeadTimings { pub head_start: Option, pub head_end: Option, + /// Whether fork choice failed to find a head, leaving the stored one + /// where it was. The Fast Confirmation Rule is not run on a stale head. + pub head_failed: bool, pub fcu_start: Option, pub fcu_end: Option, } diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 251048b7a..76133ae05 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -4,6 +4,7 @@ use ethlambda_network_api::{ DeferredFrom, FetchRequest, InitP2P, }; use ethlambda_state_transition::beacon::error::Error as BeaconError; +use ethlambda_state_transition::beacon::fast_confirmation; use ethlambda_state_transition::beacon::fork_choice; use ethlambda_state_transition::beacon::helpers::accessors::CommitteeCacheExt; use ethlambda_state_transition::is_proposer; @@ -1065,7 +1066,12 @@ impl BlockChainServer { // votes have to be in fork choice before the head this tick // reports is chosen. self.drain_deferred_aggregates(); - self.recompute_beacon_head().await; + // The Fast Confirmation Rule runs inside this recompute, once + // per slot: after the clock advance and the deferred + // aggregates (so past slots' attestations are applied) and + // after the head, but before `forkchoiceUpdated`, whose safe + // block it decides. + self.recompute_beacon_head_with(true).await; } } @@ -2307,7 +2313,19 @@ impl BlockChainServer { /// `forkchoiceUpdated` round trip, so `execution_optimistic` reflects the /// verdict it carried. async fn recompute_beacon_head(&mut self) -> HeadTimings { + self.recompute_beacon_head_with(false).await + } + + /// [`Self::recompute_beacon_head`], optionally running the Fast + /// Confirmation Rule between the head computation and the + /// `forkchoiceUpdated` that carries its confirmed block as the safe one. + /// Only the per-slot tick passes `true`: the rule must run exactly once per + /// slot, and imports recompute the head many times a slot. + async fn recompute_beacon_head_with(&mut self, run_fast_confirmation: bool) -> HeadTimings { let mut timings = self.update_head_from_fork_choice(); + if run_fast_confirmation && !timings.head_failed { + self.run_fast_confirmation(); + } if let Some((start, end)) = self.notify_forkchoice_updated().await { timings.fcu_start = Some(start); timings.fcu_end = Some(end); @@ -2503,6 +2521,7 @@ impl BlockChainServer { // what this does: the head simply does not move. warn!(%err, "Failed to compute beacon head"); timings.head_end = Some(Instant::now()); + timings.head_failed = true; return timings; } timings.head_end = Some(Instant::now()); @@ -2513,6 +2532,39 @@ impl BlockChainServer { timings } + /// Run the Fast Confirmation Rule once and publish its `fast_confirmation` + /// event, from the head fork choice just recorded. + /// + /// Skipped, and the rule's variables reset to the finalized checkpoint, + /// while the head is more than [`events::HEAD_EVENT_RECENCY_SLOTS`] behind + /// the wall clock: the rule assumes synchrony from the start of the + /// previous slot, a catching-up node has none, and its stored heads would + /// be stale (and eventually pruned). Same staleness gate as the `head` + /// event. A failure is logged and the event withheld; the next slot runs + /// again from the variables as they were. + fn run_fast_confirmation(&mut self) { + let Some((head_slot, head_root)) = self.store.beacon_head() else { + return; + }; + if head_slot + events::HEAD_EVENT_RECENCY_SLOTS < self.wall_clock_slot() { + self.store.reset_fast_confirmation(); + return; + } + let config = self.store.config(); + let result = { + let _timing = metrics::time_beacon_fast_confirmation(); + fast_confirmation::run_fast_confirmation(&self.store, &config, head_root) + }; + match result { + Ok(confirmation) => self.events.publish_fast_confirmation( + confirmation.block, + confirmation.slot, + confirmation.current_slot, + ), + Err(err) => warn!(%err, "Fast confirmation run failed"), + } + } + /// Point the committee cache's eviction at the head fork choice just /// recorded, so the shufflings that head's children will ask for are /// never the ones a full cache drops; see `CommitteeCache::update_head`. diff --git a/crates/blockchain/src/metrics.rs b/crates/blockchain/src/metrics.rs index 34c67398f..8ebd2c60f 100644 --- a/crates/blockchain/src/metrics.rs +++ b/crates/blockchain/src/metrics.rs @@ -409,6 +409,24 @@ static LEAN_BEACON_HEAD_COMPUTE_TIME_SECONDS: std::sync::LazyLock = .unwrap() }); +/// One `on_fast_confirmation` run of the beacon Fast Confirmation Rule: the +/// registry flattening, the per-block safety checks and the variable +/// bookkeeping, excluding the head computation that precedes it. +/// +/// Buckets reach a whole mainnet slot, since a run that costs seconds is one +/// the chain actor cannot afford at the start of a slot. +static LEAN_BEACON_FAST_CONFIRMATION_TIME_SECONDS: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_histogram!( + "lean_beacon_fast_confirmation_time_seconds", + "Duration of one beacon Fast Confirmation Rule run", + vec![ + 0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 4.0, 12.0 + ] + ) + .unwrap() + }); + static LEAN_PQ_SIG_ATTESTATION_SIGNING_TIME_SECONDS: std::sync::LazyLock = std::sync::LazyLock::new(|| { register_histogram!( @@ -1188,6 +1206,12 @@ pub fn time_beacon_head_compute() -> TimingGuard { TimingGuard::new(&LEAN_BEACON_HEAD_COMPUTE_TIME_SECONDS) } +/// Start timing one beacon Fast Confirmation Rule run. Records when the guard +/// is dropped, including on the error path, like [`time_beacon_head_compute`]. +pub fn time_beacon_fast_confirmation() -> TimingGuard { + TimingGuard::new(&LEAN_BEACON_FAST_CONFIRMATION_TIME_SECONDS) +} + /// Increment the PQ aggregated signatures counter. pub fn inc_pq_sig_aggregated_signatures() { LEAN_PQ_SIG_AGGREGATED_SIGNATURES_TOTAL.inc(); diff --git a/crates/blockchain/state_transition/src/beacon/fast_confirmation.rs b/crates/blockchain/state_transition/src/beacon/fast_confirmation.rs new file mode 100644 index 000000000..ce03dc164 --- /dev/null +++ b/crates/blockchain/state_transition/src/beacon/fast_confirmation.rs @@ -0,0 +1,2734 @@ +//! The Fast Confirmation Rule (FCR), after consensus-specs' `phase0`, +//! `bellatrix` and `gloas` `fast-confirmation.md`. +//! +//! **Spec version: consensus-specs `master` at commit `f993cf2`**, not the +//! `v1.7.0-beta.2` the rest of this crate is pinned to. The release ships no +//! FCR fixtures, so nothing ties this module to it, and `master` carries +//! fixes the release lacks: the gloas empty-slot support discount (#5672), +//! precise committee weight (#5670), the restart-resilient confirmed root +//! (#5673) and the `support_discount <= maximum_support` assertion (#5788). +//! A later bump should diff the three files above against that commit. +//! +//! One Rust function per specification function, named as the specification +//! names it, so the two can be read side by side. Two deliberate shapes differ +//! from the Python: +//! +//! - The specification's `FastConfirmationStore` holds a read-only `Store` +//! beside the rule's own variables. [`FastConfirmationStore`] does the same +//! (a borrowed [`Store`] plus the variables), and every function takes it +//! where the specification takes `store` or `fcr_store`, so a function that +//! takes both in Python takes one argument here. +//! - The variables live in the `Store` between runs +//! ([`Store::fast_confirmation`]); [`run_fast_confirmation`] loads them, +//! builds a [`FastConfirmationStore`] around them, runs +//! [`on_fast_confirmation`] and writes them back. +//! +//! # Cost +//! +//! The specification is written for clarity: `get_node_support_between_slots`, +//! `compute_committee_weight_between_slots` and `get_attestation_score` each +//! walk the validator registry or the committees, and they are called once +//! per block of the confirmed chain. On mainnet (about a million active +//! validators) that would be tens of registry passes per slot, so the +//! per-run work is shared instead, in a [`RunContext`] built with the +//! [`FastConfirmationStore`] and dropped with it: +//! +//! - one pass over each balance source's registry ([`BalanceSource`]), at most +//! three per run (previous and current observed justified checkpoints, and +//! the pulled-up head state), shared by every later read; +//! - one bottom-up fold over the latest messages per balance source gives +//! every block's `get_attestation_score` ([`attestation_scores`]), as +//! `compute_weights` does for the head, instead of one scan per block; +//! - one sum per `(source, slot)` of a slot's committee weight, so a range +//! inside one epoch is a sum over slots; a range crossing an epoch boundary +//! without covering an epoch is the only case that deduplicates validators, +//! and it is the smaller ranges; +//! - committees come from the store's shared committee cache. +//! +//! A run is therefore `O(V + M + B log B)` for `V` validators (the registry +//! passes), `M` latest messages and `B` unfinalized blocks, plus +//! `O(committee range)` for the few epoch-crossing or empty-slot ranges and +//! `O(votes * depth)` only in [`get_current_target_score`], which resolves +//! one checkpoint per distinct `(root, epoch)` vote rather than per vote. +//! +//! # Where the Rust departs from the Python +//! +//! - `store.blocks` is the store's live chain index, with the block table as a +//! fallback for a root below the pruned window (see [`block`]). +//! - `store.unrealized_justifications[root]` falls back to the store's +//! justified checkpoint for a block with no recorded value, as +//! [`fork_choice::get_voting_source`] does, instead of raising. +//! - [`is_one_confirmed`] returns `False` for an optimistic (`NOT_VALIDATED`) +//! block, which the specification requires in prose. +//! - [`get_slot_committee`] returns a `Vec`, not a `set`: one slot's committees +//! are disjoint. Ranges across slots deduplicate where an epoch boundary +//! makes that possible. +//! - A validator index past a balance source's registry counts as inactive +//! there, where Python would raise `IndexError`. +//! - `get_root_confirmed_before_restart` is the root the previous process +//! persisted ([`Store::take_fast_confirmation_restart_root`]). + +use std::cell::{OnceCell, RefCell}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use ethlambda_storage::Store; +pub use ethlambda_types::beacon::fork_choice::FastConfirmationVariables; + +use crate::beacon::committees::EpochCommittees; +use crate::beacon::config::Config; +use crate::beacon::containers::{BeaconState, Checkpoint}; +use crate::beacon::error::{Error, Result, verify}; +use crate::beacon::fork_choice::{ + self, ForkChoiceNode, ForkRules, LatestMessage, PayloadStatus, compute_slots_since_epoch_start, +}; +use crate::beacon::helpers::accessors::{CommitteeCacheExt, get_current_epoch}; +use crate::beacon::helpers::misc::{compute_epoch_at_slot, compute_start_slot_at_epoch}; +use crate::beacon::helpers::predicates::is_active_validator; +use crate::beacon::preset; +use crate::beacon::primitives::{Epoch, Gwei, Root, Slot, ValidatorIndex}; + +/// Assumed maximum percentage of Byzantine validators among the validator set. +/// Not read from `config.yaml`: the key is not a [`Config`] field, and the +/// specification caps it at this same value. +pub const CONFIRMATION_BYZANTINE_THRESHOLD: u64 = 25; + +// --------------------------------------------------------------------------- +// FastConfirmationStore +// --------------------------------------------------------------------------- + +/// `FastConfirmationStore`: the rule's variables around a read-only view of +/// the fork-choice [`Store`], plus the per-run memo described in the module +/// documentation. +/// +/// `head` is `get_head(store).root`, computed by the caller once and fixed for +/// the run: nothing the rule does moves it. +pub struct FastConfirmationStore<'a> { + pub store: &'a Store, + pub config: &'a Config, + pub confirmed_root: Root, + pub previous_epoch_observed_justified_checkpoint: Checkpoint, + pub current_epoch_observed_justified_checkpoint: Checkpoint, + pub previous_epoch_greatest_unrealized_checkpoint: Checkpoint, + pub previous_slot_head: Root, + pub current_slot_head: Root, + run: RunContext, +} + +impl<'a> FastConfirmationStore<'a> { + /// A store around `variables`, for a run whose head is `head`. + pub fn new( + store: &'a Store, + config: &'a Config, + variables: FastConfirmationVariables, + head: Root, + ) -> Self { + Self { + store, + config, + confirmed_root: variables.confirmed_root, + previous_epoch_observed_justified_checkpoint: variables + .previous_epoch_observed_justified_checkpoint, + current_epoch_observed_justified_checkpoint: variables + .current_epoch_observed_justified_checkpoint, + previous_epoch_greatest_unrealized_checkpoint: variables + .previous_epoch_greatest_unrealized_checkpoint, + previous_slot_head: variables.previous_slot_head, + current_slot_head: variables.current_slot_head, + run: RunContext::new(store, config, head), + } + } + + /// The variables to carry to the next run. + pub fn variables(&self) -> FastConfirmationVariables { + FastConfirmationVariables { + confirmed_root: self.confirmed_root, + previous_epoch_observed_justified_checkpoint: self + .previous_epoch_observed_justified_checkpoint, + current_epoch_observed_justified_checkpoint: self + .current_epoch_observed_justified_checkpoint, + previous_epoch_greatest_unrealized_checkpoint: self + .previous_epoch_greatest_unrealized_checkpoint, + previous_slot_head: self.previous_slot_head, + current_slot_head: self.current_slot_head, + } + } + + /// `get_head(store).root`, fixed for the run. + pub fn head(&self) -> Root { + self.run.head + } + + /// `get_current_slot(store)`. + pub fn current_slot(&self) -> Slot { + self.run.current_slot + } + + /// `get_current_store_epoch(store)`. + pub fn current_epoch(&self) -> Epoch { + self.run.current_epoch + } +} + +/// Which state a [`BalanceSource`] was built from, which is also its memo key. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum BalanceKey { + /// `store.checkpoint_states[checkpoint]`. + Checkpoint(Epoch, Root), + /// `get_pulled_up_head_state(store)`. + PulledUpHead, +} + +impl BalanceKey { + fn of(checkpoint: Checkpoint) -> Self { + BalanceKey::Checkpoint(checkpoint.epoch, checkpoint.root) + } +} + +/// A registry flattened for the rule's sums, built in one pass over a state's +/// validators. +/// +/// Every sum the specification takes over a balance source is a sum of +/// effective balances over validators that are active at the state's epoch, +/// and, for most, unslashed. A validator that fails the condition contributes +/// zero, so the two arrays below are all a sum needs, and "is this validator +/// in the set" never has to be asked on its own: an active validator with a +/// zero effective balance contributes the same zero either way. +pub struct BalanceSource { + key: BalanceKey, + /// Effective balance if active at the state's epoch, else zero (slashed + /// validators included). + active: Vec, + /// Effective balance if active and not slashed, else zero. + unslashed: Vec, + /// The sum of `active`, before `get_total_balance`'s floor. + total_active: Gwei, + /// The sum of `unslashed`, before `get_total_balance`'s floor. + total_unslashed: Gwei, +} + +impl BalanceSource { + fn build(key: BalanceKey, state: &BeaconState) -> Self { + let epoch = get_current_epoch(state); + let mut active = Vec::with_capacity(state.validator_count()); + let mut unslashed = Vec::with_capacity(state.validator_count()); + let (mut total_active, mut total_unslashed) = (0u64, 0u64); + for validator in state.iter_validators() { + let balance = if is_active_validator(validator, epoch) { + validator.effective_balance + } else { + 0 + }; + let unslashed_balance = if validator.slashed { 0 } else { balance }; + total_active = total_active.saturating_add(balance); + total_unslashed = total_unslashed.saturating_add(unslashed_balance); + active.push(balance); + unslashed.push(unslashed_balance); + } + Self { + key, + active, + unslashed, + total_active, + total_unslashed, + } + } + + fn active(&self, index: ValidatorIndex) -> Gwei { + self.active.get(index as usize).copied().unwrap_or(0) + } + + fn unslashed(&self, index: ValidatorIndex) -> Gwei { + self.unslashed.get(index as usize).copied().unwrap_or(0) + } +} + +/// What one run computes once and every function after it reads. Interior +/// mutability so the specification's `&store`-shaped functions stay `&`. +struct RunContext { + head: Root, + current_slot: Slot, + current_epoch: Epoch, + rules: ForkRules, + /// `root -> (slot, parent_root)` of the live chain. + index: HashMap, + /// `store.equivocating_indices`, as one snapshot. + equivocating: HashSet, + head_state: OnceCell>, + pulled_up_head_state: OnceCell>, + committees: RefCell>>, + slot_committees: RefCell>>>, + sources: RefCell>>, + scores: RefCell>>>, + slot_weights: RefCell>, + range_weights: RefCell>, + equivocation_scores: RefCell>, +} + +impl RunContext { + fn new(store: &Store, config: &Config, head: Root) -> Self { + let current_slot = fork_choice::get_current_slot(store, config); + let current_epoch = compute_epoch_at_slot(current_slot); + Self { + head, + current_slot, + current_epoch, + rules: ForkRules::of(config.fork_at_epoch(current_epoch)), + index: store.block_index(), + equivocating: store.equivocating_indices(), + head_state: OnceCell::new(), + pulled_up_head_state: OnceCell::new(), + committees: RefCell::default(), + slot_committees: RefCell::default(), + sources: RefCell::default(), + scores: RefCell::default(), + slot_weights: RefCell::default(), + range_weights: RefCell::default(), + equivocation_scores: RefCell::default(), + } + } +} + +/// `get_fast_confirmation_store`: every variable at the finalized checkpoint. +/// +/// Conservative: the confirmed block is advanced at the first epoch boundary +/// whose starting conditions hold, and until then the finalized block is the +/// confirmed one. +pub fn get_fast_confirmation_store<'a>( + store: &'a Store, + config: &'a Config, + head: Root, +) -> FastConfirmationStore<'a> { + let variables = FastConfirmationVariables::at_finalized(store.beacon_finalized_checkpoint()); + FastConfirmationStore::new(store, config, variables, head) +} + +// --------------------------------------------------------------------------- +// Misc helper functions +// --------------------------------------------------------------------------- + +/// `get_node_for_root`, with gloas's modification: a gloas node is `PENDING`, +/// matching either payload branch of the block. Pre-gloas nodes carry +/// [`PayloadStatus::Full`], as everywhere in this crate's fork choice. +pub fn get_node_for_root(fcr_store: &FastConfirmationStore, block_root: Root) -> ForkChoiceNode { + let payload_status = match fcr_store.run.rules { + ForkRules::PreGloas => PayloadStatus::Full, + ForkRules::Gloas => PayloadStatus::Pending, + }; + ForkChoiceNode { + root: block_root, + payload_status, + } +} + +/// `store.blocks[root]`, as `(slot, parent_root)`. +/// +/// The live chain index first; the block table for a root below the window the +/// index prunes (a checkpoint older than the finalized block), which is rare +/// and costs a block decode. +fn block(fcr_store: &FastConfirmationStore, root: Root) -> Result<(Slot, Root)> { + if let Some(entry) = fcr_store.run.index.get(&root) { + return Ok(*entry); + } + fcr_store + .store + .block_entry(&root) + .ok_or(Error::SpecAssert("block_root in store.blocks")) +} + +/// `get_block_slot`. +pub fn get_block_slot(fcr_store: &FastConfirmationStore, block_root: Root) -> Result { + Ok(block(fcr_store, block_root)?.0) +} + +/// `get_block_epoch`. +pub fn get_block_epoch(fcr_store: &FastConfirmationStore, block_root: Root) -> Result { + Ok(compute_epoch_at_slot(get_block_slot( + fcr_store, block_root, + )?)) +} + +/// `get_ancestor` of `root` at `slot`, as a root: the pre-gloas rule, which +/// gloas's payload-aware version reduces to once the payload status is +/// dropped (its descent picks the next root by block slot alone). +fn get_ancestor_root(fcr_store: &FastConfirmationStore, root: Root, slot: Slot) -> Result { + let mut root = root; + loop { + let (block_slot, parent_root) = block(fcr_store, root)?; + if block_slot > slot { + root = parent_root; + } else { + return Ok(root); + } + } +} + +/// `is_ancestor` between two `get_node_for_root` nodes. +/// +/// Every call in this module passes a [`get_node_for_root`] node as +/// `ancestor`: under gloas that is `PENDING`, the wildcard, for which the +/// payload-aware `is_ancestor` reduces to this root comparison. +fn is_ancestor( + fcr_store: &FastConfirmationStore, + node_root: Root, + ancestor_root: Root, +) -> Result { + let ancestor_slot = get_block_slot(fcr_store, ancestor_root)?; + Ok(get_ancestor_root(fcr_store, node_root, ancestor_slot)? == ancestor_root) +} + +/// `get_checkpoint_for_block`: the checkpoint at `epoch` in the chain of +/// `block_root`. +pub fn get_checkpoint_for_block( + fcr_store: &FastConfirmationStore, + block_root: Root, + epoch: Epoch, +) -> Result { + Ok(Checkpoint { + epoch, + root: get_ancestor_root(fcr_store, block_root, compute_start_slot_at_epoch(epoch))?, + }) +} + +/// `get_current_target`: the current epoch's target on the head's chain. +pub fn get_current_target(fcr_store: &FastConfirmationStore) -> Result { + get_checkpoint_for_block(fcr_store, fcr_store.run.head, fcr_store.run.current_epoch) +} + +/// `is_start_slot_at_epoch`. +pub fn is_start_slot_at_epoch(slot: Slot) -> bool { + compute_slots_since_epoch_start(slot) == 0 +} + +/// `get_ancestor_roots`: ancestors of `block_root`, itself included, down to +/// but excluding `terminal_root`, oldest first. Empty if `terminal_root` is +/// not on `block_root`'s chain. +pub fn get_ancestor_roots( + fcr_store: &FastConfirmationStore, + block_root: Root, + terminal_root: Root, +) -> Result> { + let terminal_slot = get_block_slot(fcr_store, terminal_root)?; + let mut root = block_root; + let mut ancestor_roots = Vec::new(); + loop { + let (slot, parent_root) = block(fcr_store, root)?; + if slot <= terminal_slot { + // Terminal root not reached on this chain. + return Ok(Vec::new()); + } + ancestor_roots.push(root); + root = parent_root; + if root == terminal_root { + ancestor_roots.reverse(); + return Ok(ancestor_roots); + } + } +} + +// --------------------------------------------------------------------------- +// State helpers +// --------------------------------------------------------------------------- + +/// The head's post-state, `store.block_states[head]`. +fn head_state(fcr_store: &FastConfirmationStore) -> Result> { + if let Some(state) = fcr_store.run.head_state.get() { + return Ok(Arc::clone(state)); + } + let state = fcr_store + .store + .get_state(&fcr_store.run.head) + .expect("get") + .ok_or(Error::SpecAssert("head in store.block_states"))?; + let _ = fcr_store.run.head_state.set(Arc::clone(&state)); + Ok(state) +} + +/// `epoch`'s committees as the head's state names them, from the store's +/// shared cache and memoized for the run. +fn epoch_committees( + fcr_store: &FastConfirmationStore, + epoch: Epoch, +) -> Result> { + if let Some(committees) = fcr_store.run.committees.borrow().get(&epoch) { + return Ok(Arc::clone(committees)); + } + let state = head_state(fcr_store)?; + let committees = fcr_store.store.committee_cache().committees(&state, epoch); + fcr_store + .run + .committees + .borrow_mut() + .insert(epoch, Arc::clone(&committees)); + Ok(committees) +} + +/// `get_slot_committee`: participants of all committees in `slot`. +/// +/// Supports every epoch from `current_epoch - 2`, the farthest the rule asks +/// for. A `Vec` rather than a set: the committees of one slot are disjoint. +pub fn get_slot_committee( + fcr_store: &FastConfirmationStore, + slot: Slot, +) -> Result>> { + if let Some(participants) = fcr_store.run.slot_committees.borrow().get(&slot) { + return Ok(Arc::clone(participants)); + } + let committees = epoch_committees(fcr_store, compute_epoch_at_slot(slot))?; + let mut participants = Vec::new(); + for index in 0..committees.committees_per_slot() { + participants.extend_from_slice(committees.committee(slot, index)?); + } + let participants = Arc::new(participants); + fcr_store + .run + .slot_committees + .borrow_mut() + .insert(slot, Arc::clone(&participants)); + Ok(participants) +} + +/// Participants of the committees of every slot in `[start_slot, end_slot]`, +/// each validator once. +/// +/// Inside one epoch the slots' committees are disjoint, so they concatenate; +/// across an epoch boundary a validator can sit in both epochs' committees, +/// and the specification's `set` is replaced by a bitmap. +fn participants_between_slots( + fcr_store: &FastConfirmationStore, + start_slot: Slot, + end_slot: Slot, +) -> Result> { + let mut participants = Vec::new(); + if start_slot > end_slot { + return Ok(participants); + } + let crosses_epochs = compute_epoch_at_slot(start_slot) != compute_epoch_at_slot(end_slot); + let mut seen: Vec = Vec::new(); + for slot in start_slot..=end_slot { + for &index in get_slot_committee(fcr_store, slot)?.iter() { + if crosses_epochs { + let (word, bit) = ((index / 64) as usize, index % 64); + if seen.len() <= word { + seen.resize(word + 1, 0); + } + if seen[word] & (1 << bit) != 0 { + continue; + } + seen[word] |= 1 << bit; + } + participants.push(index); + } + } + Ok(participants) +} + +/// `get_pulled_up_head_state`: the head's state, advanced to the current +/// epoch's first slot if it is from an earlier epoch. +/// +/// Read through `checkpoint_state` for `(current_epoch, head)`, which is +/// exactly that advance, so a state the epoch precompute already built is +/// reused rather than replayed. +pub fn get_pulled_up_head_state(fcr_store: &FastConfirmationStore) -> Result> { + if let Some(state) = fcr_store.run.pulled_up_head_state.get() { + return Ok(Arc::clone(state)); + } + let head_state = head_state(fcr_store)?; + let state = if get_current_epoch(&head_state) < fcr_store.run.current_epoch { + let checkpoint = Checkpoint { + epoch: fcr_store.run.current_epoch, + root: fcr_store.run.head, + }; + fork_choice::checkpoint_state(fcr_store.store, &checkpoint, fcr_store.config)? + } else { + head_state + }; + let _ = fcr_store.run.pulled_up_head_state.set(Arc::clone(&state)); + Ok(state) +} + +/// The flattened registry of `key`'s state, built once per run. +fn balance_source( + fcr_store: &FastConfirmationStore, + key: BalanceKey, +) -> Result> { + if let Some(source) = fcr_store.run.sources.borrow().get(&key) { + return Ok(Arc::clone(source)); + } + let state = match key { + BalanceKey::Checkpoint(epoch, root) => fork_choice::checkpoint_state( + fcr_store.store, + &Checkpoint { epoch, root }, + fcr_store.config, + )?, + BalanceKey::PulledUpHead => get_pulled_up_head_state(fcr_store)?, + }; + let source = Arc::new(BalanceSource::build(key, &state)); + fcr_store + .run + .sources + .borrow_mut() + .insert(key, Arc::clone(&source)); + Ok(source) +} + +/// `get_previous_balance_source`: the state of the previous epoch's observed +/// justified checkpoint. +fn get_previous_balance_source(fcr_store: &FastConfirmationStore) -> Result> { + balance_source( + fcr_store, + BalanceKey::of(fcr_store.previous_epoch_observed_justified_checkpoint), + ) +} + +/// `get_current_balance_source`: the state of the current epoch's observed +/// justified checkpoint. +fn get_current_balance_source(fcr_store: &FastConfirmationStore) -> Result> { + balance_source( + fcr_store, + BalanceKey::of(fcr_store.current_epoch_observed_justified_checkpoint), + ) +} + +/// `get_total_balance(source, indices)`'s floor: at least one +/// `EFFECTIVE_BALANCE_INCREMENT`, so no caller divides by zero. +fn total_balance(sum: Gwei) -> Gwei { + sum.max(preset::EFFECTIVE_BALANCE_INCREMENT) +} + +/// `get_total_active_balance(balance_source)`. +fn get_total_active_balance(balance_source: &BalanceSource) -> Gwei { + total_balance(balance_source.total_active) +} + +/// `compute_proposer_score(balance_source)`. +fn compute_proposer_score( + fcr_store: &FastConfirmationStore, + balance_source: &BalanceSource, +) -> Gwei { + fork_choice::committee_fraction( + total_balance(balance_source.total_unslashed), + fcr_store.config.proposer_score_boost, + ) +} + +/// `current_slot - 1`, which the specification computes on a `Uint64` and so +/// raises at slot zero. +fn previous_slot(fcr_store: &FastConfirmationStore) -> Result { + fcr_store + .run + .current_slot + .checked_sub(1) + .ok_or(Error::ArithmeticOverflow("current_slot - 1")) +} + +// --------------------------------------------------------------------------- +// LMD-GHOST helpers +// --------------------------------------------------------------------------- + +/// Whether `message` supports `node`, the equality in +/// `get_supported_node(store, message) == node`. +/// +/// Before gloas a node is a bare root. Under gloas the root is compared first, +/// which settles all but the votes for the block itself, and only those pay +/// for `get_supported_node`'s payload branch. +fn supports_node( + fcr_store: &FastConfirmationStore, + message: LatestMessage, + node: ForkChoiceNode, +) -> Result { + if message.root != node.root { + return Ok(false); + } + match fcr_store.run.rules { + ForkRules::PreGloas => Ok(true), + ForkRules::Gloas => Ok(fork_choice::get_supported_node(fcr_store.store, message)? == node), + } +} + +/// `get_node_support_between_slots`: support of `node` by validators assigned +/// to the slots in `[start_slot, end_slot]`, unslashed and active in +/// `balance_source`, excluding equivocators. +pub fn get_node_support_between_slots( + fcr_store: &FastConfirmationStore, + balance_source: &BalanceSource, + node: ForkChoiceNode, + start_slot: Slot, + end_slot: Slot, +) -> Result { + let mut support: Gwei = 0; + for index in participants_between_slots(fcr_store, start_slot, end_slot)? { + let balance = balance_source.unslashed(index); + if balance == 0 || fcr_store.run.equivocating.contains(&index) { + continue; + } + let Some(message) = fcr_store.store.latest_message(index) else { + continue; + }; + if supports_node(fcr_store, message, node)? { + support = support.saturating_add(balance); + } + } + Ok(support) +} + +/// `is_full_validator_set_covered`: whether `[start_slot, end_slot]` includes +/// an entire epoch. +pub fn is_full_validator_set_covered(start_slot: Slot, end_slot: Slot) -> bool { + let start_full_epoch = compute_epoch_at_slot(start_slot + preset::SLOTS_PER_EPOCH - 1); + let end_full_epoch = compute_epoch_at_slot(end_slot + 1); + start_full_epoch < end_full_epoch +} + +/// The weight of one slot's committees in `balance_source`, memoized. +fn slot_committee_weight( + fcr_store: &FastConfirmationStore, + balance_source: &BalanceSource, + slot: Slot, +) -> Result { + let key = (balance_source.key, slot); + if let Some(weight) = fcr_store.run.slot_weights.borrow().get(&key) { + return Ok(*weight); + } + let weight = get_slot_committee(fcr_store, slot)? + .iter() + .fold(0, |sum: Gwei, &index| { + sum.saturating_add(balance_source.active(index)) + }); + fcr_store.run.slot_weights.borrow_mut().insert(key, weight); + Ok(weight) +} + +/// `compute_committee_weight_between_slots`: total weight of the committees +/// of `[start_slot, end_slot]`. +pub fn compute_committee_weight_between_slots( + fcr_store: &FastConfirmationStore, + balance_source: &BalanceSource, + start_slot: Slot, + end_slot: Slot, +) -> Result { + // Sanity check + if start_slot > end_slot { + return Ok(0); + } + + // If an entire epoch is covered by the range, return the total active balance + if is_full_validator_set_covered(start_slot, end_slot) { + return Ok(get_total_active_balance(balance_source)); + } + + let key = (balance_source.key, start_slot, end_slot); + if let Some(weight) = fcr_store.run.range_weights.borrow().get(&key) { + return Ok(*weight); + } + + // Validators assigned to committees of the range that are active in the + // balance source; `active` is zero for the others. + let weight = if compute_epoch_at_slot(start_slot) == compute_epoch_at_slot(end_slot) { + let mut sum: Gwei = 0; + for slot in start_slot..=end_slot { + sum = sum.saturating_add(slot_committee_weight(fcr_store, balance_source, slot)?); + } + sum + } else { + participants_between_slots(fcr_store, start_slot, end_slot)? + .into_iter() + .fold(0, |sum: Gwei, index| { + sum.saturating_add(balance_source.active(index)) + }) + }; + let weight = total_balance(weight); + fcr_store.run.range_weights.borrow_mut().insert(key, weight); + Ok(weight) +} + +/// `get_equivocation_score`: total weight of the equivocating participants of +/// the committees of `[start_slot, end_slot]`. +/// +/// Like the specification, it does not look in `balance_source` for slashed +/// validators: those are very likely already equivocators. +pub fn get_equivocation_score( + fcr_store: &FastConfirmationStore, + balance_source: &BalanceSource, + start_slot: Slot, + end_slot: Slot, +) -> Result { + // The common case: nobody has equivocated, and there is nothing to intersect. + if fcr_store.run.equivocating.is_empty() { + return Ok(0); + } + let key = (balance_source.key, start_slot, end_slot); + if let Some(score) = fcr_store.run.equivocation_scores.borrow().get(&key) { + return Ok(*score); + } + let score = participants_between_slots(fcr_store, start_slot, end_slot)? + .into_iter() + .filter(|index| fcr_store.run.equivocating.contains(index)) + .fold(0, |sum: Gwei, index| { + sum.saturating_add(balance_source.active(index)) + }); + fcr_store + .run + .equivocation_scores + .borrow_mut() + .insert(key, score); + Ok(score) +} + +/// `compute_adversarial_weight`: the most weight that can be adversarial in +/// the committees of `[start_slot, end_slot]`, assuming +/// [`CONFIRMATION_BYZANTINE_THRESHOLD`] and discounting equivocators. +pub fn compute_adversarial_weight( + fcr_store: &FastConfirmationStore, + balance_source: &BalanceSource, + start_slot: Slot, + end_slot: Slot, +) -> Result { + let maximum_weight = + compute_committee_weight_between_slots(fcr_store, balance_source, start_slot, end_slot)?; + let max_adversarial_weight = maximum_weight / 100 * CONFIRMATION_BYZANTINE_THRESHOLD; + + // Discount total weight of equivocating validators + let equivocation_score = + get_equivocation_score(fcr_store, balance_source, start_slot, end_slot)?; + Ok(max_adversarial_weight.saturating_sub(equivocation_score)) +} + +/// `get_adversarial_weight`: the most adversarial weight that can support the +/// block. +pub fn get_adversarial_weight( + fcr_store: &FastConfirmationStore, + balance_source: &BalanceSource, + block_root: Root, +) -> Result { + let current_slot_minus_one = previous_slot(fcr_store)?; + let (block_slot, parent_root) = block(fcr_store, block_root)?; + if get_block_epoch(fcr_store, block_root)? > get_block_epoch(fcr_store, parent_root)? { + // Use the first epoch slot as the start slot when crossing epoch boundary + let start_slot = compute_start_slot_at_epoch(get_block_epoch(fcr_store, block_root)?); + compute_adversarial_weight( + fcr_store, + balance_source, + start_slot, + current_slot_minus_one, + ) + } else { + compute_adversarial_weight( + fcr_store, + balance_source, + block_slot, + current_slot_minus_one, + ) + } +} + +/// `compute_empty_slot_support_discount`: weight that can be discounted from +/// the safety threshold when empty slots precede the block. +pub fn compute_empty_slot_support_discount( + fcr_store: &FastConfirmationStore, + balance_source: &BalanceSource, + block_root: Root, +) -> Result { + let (block_slot, parent_root) = block(fcr_store, block_root)?; + let (parent_slot, _) = block(fcr_store, parent_root)?; + // No empty slot + if parent_slot + 1 == block_slot { + return Ok(0); + } + + // Discount votes supporting the parent block if they are from the committees of empty slots. + // Gloas's `get_ancestor` names the parent node by the payload branch this block builds on; + // before it the node is the bare root. + let parent_node = match fcr_store.run.rules { + ForkRules::Gloas => fork_choice::gloas_get_ancestor( + fcr_store.store, + get_node_for_root(fcr_store, block_root), + parent_slot, + )?, + ForkRules::PreGloas => ForkChoiceNode { + root: get_ancestor_root(fcr_store, block_root, parent_slot)?, + payload_status: PayloadStatus::Full, + }, + }; + let parent_support_in_empty_slots = get_node_support_between_slots( + fcr_store, + balance_source, + parent_node, + parent_slot + 1, + block_slot - 1, + )?; + // Adversarial weight is not discounted + let adversarial_weight = + compute_adversarial_weight(fcr_store, balance_source, parent_slot + 1, block_slot - 1)?; + Ok(parent_support_in_empty_slots.saturating_sub(adversarial_weight)) +} + +/// `get_support_discount`: weight that can be discounted during the safety +/// threshold computation for the block. +pub fn get_support_discount( + fcr_store: &FastConfirmationStore, + balance_source: &BalanceSource, + block_root: Root, +) -> Result { + compute_empty_slot_support_discount(fcr_store, balance_source, block_root) +} + +/// `compute_safety_threshold`: the LMD-GHOST safety threshold for +/// `block_root`. +pub fn compute_safety_threshold( + fcr_store: &FastConfirmationStore, + block_root: Root, + balance_source: &BalanceSource, +) -> Result { + let current_slot_minus_one = previous_slot(fcr_store)?; + let (_, parent_root) = block(fcr_store, block_root)?; + let (parent_slot, _) = block(fcr_store, parent_root)?; + + let proposer_score = compute_proposer_score(fcr_store, balance_source); + let maximum_support = compute_committee_weight_between_slots( + fcr_store, + balance_source, + parent_slot + 1, + current_slot_minus_one, + )?; + let support_discount = get_support_discount(fcr_store, balance_source, block_root)?; + let adversarial_weight = get_adversarial_weight(fcr_store, balance_source, block_root)?; + + // With precise committee weight support_discount must never exceed maximum_support + verify( + support_discount <= maximum_support, + "support_discount <= maximum_support", + )?; + + // (maximum_support + proposer_score - support_discount) // 2 + adversarial_weight + Ok((maximum_support + proposer_score + 2 * adversarial_weight - support_discount) / 2) +} + +/// `get_attestation_score(store, get_node_for_root(root), balance_source)` +/// for every block at once. +/// +/// A vote counts for a block exactly when the block it supports descends from +/// it, so each vote is summed at its own block and every block's total is +/// folded into its parent, highest slot first (the fold `compute_weights` +/// does for the head). Under gloas the node is `PENDING`, which matches either +/// payload branch, so the walk is by root there too. A vote for a block +/// outside the index (pruned below finality) descends from no block scored +/// here, and the fold never reaches it. +fn attestation_scores( + fcr_store: &FastConfirmationStore, + balance_source: &BalanceSource, +) -> Arc> { + if let Some(scores) = fcr_store.run.scores.borrow().get(&balance_source.key) { + return Arc::clone(scores); + } + let mut scores: HashMap = HashMap::new(); + // Nothing may read the store's own scratch inside this closure. + fcr_store + .store + .for_each_non_equivocating_latest_message(|index, message| { + let balance = balance_source.unslashed(index); + if balance != 0 { + let entry = scores.entry(message.root).or_default(); + *entry = entry.saturating_add(balance); + } + }); + + let mut blocks: Vec<(Root, Slot, Root)> = fcr_store + .run + .index + .iter() + .map(|(root, (slot, parent_root))| (*root, *slot, *parent_root)) + .collect(); + blocks.sort_unstable_by(|left, right| right.1.cmp(&left.1).then(right.0.cmp(&left.0))); + for (root, _slot, parent_root) in &blocks { + let score = scores.get(root).copied().unwrap_or_default(); + if score != 0 && fcr_store.run.index.contains_key(parent_root) { + let entry = scores.entry(*parent_root).or_default(); + *entry = entry.saturating_add(score); + } + } + + let scores = Arc::new(scores); + fcr_store + .run + .scores + .borrow_mut() + .insert(balance_source.key, Arc::clone(&scores)); + scores +} + +/// `get_attestation_score` of the block at `block_root` in `balance_source`. +fn get_attestation_score( + fcr_store: &FastConfirmationStore, + balance_source: &BalanceSource, + block_root: Root, +) -> Gwei { + attestation_scores(fcr_store, balance_source) + .get(&block_root) + .copied() + .unwrap_or(0) +} + +/// `is_one_confirmed`: whether the block is LMD-GHOST safe. +/// +/// `False` for a block whose payload the execution client has not validated +/// (`NOT_VALIDATED`), as the specification requires. +pub fn is_one_confirmed( + fcr_store: &FastConfirmationStore, + balance_source: &BalanceSource, + block_root: Root, +) -> Result { + if fcr_store.store.is_beacon_optimistic(block_root) { + return Ok(false); + } + let support = get_attestation_score(fcr_store, balance_source, block_root); + let safety_threshold = compute_safety_threshold(fcr_store, block_root, balance_source)?; + Ok(support > safety_threshold) +} + +/// `is_confirmed_chain_safe`: whether every block of the confirmed chain from +/// the current epoch's observed justified checkpoint is LMD-GHOST safe. +pub fn is_confirmed_chain_safe( + fcr_store: &FastConfirmationStore, + confirmed_root: Root, +) -> Result { + // Check if the confirmed_root has current_epoch_observed_justified_checkpoint in its chain + let observed = fcr_store.current_epoch_observed_justified_checkpoint; + if observed != get_checkpoint_for_block(fcr_store, confirmed_root, observed.epoch)? { + return Ok(false); + } + + let current_epoch = fcr_store.run.current_epoch; + let start_root_exclusive = if observed.epoch + 1 >= current_epoch { + // Exclude the justified checkpoint block if it is from the previous epoch + // as then this block will always be canonical in this case. + observed.root + } else { + // Limit reconfirmation to the first block of the previous epoch + // as if it is successful, reconfirmation of the ancestors is implied. + let ancestor_at_previous_epoch_start = get_ancestor_root( + fcr_store, + confirmed_root, + compute_start_slot_at_epoch(current_epoch - 1), + )?; + if get_block_epoch(fcr_store, ancestor_at_previous_epoch_start)? + 1 == current_epoch { + // The parent of the first block of the previous epoch + block(fcr_store, ancestor_at_previous_epoch_start)?.1 + } else { + // The last block of the epoch before the previous one + ancestor_at_previous_epoch_start + } + }; + + // Run is_one_confirmed for each block in the confirmed chain with the previous epoch balance source + let chain_roots = get_ancestor_roots(fcr_store, confirmed_root, start_root_exclusive)?; + let previous_balance_source = get_previous_balance_source(fcr_store)?; + for root in chain_roots { + if !is_one_confirmed(fcr_store, &previous_balance_source, root)? { + return Ok(false); + } + } + Ok(true) +} + +// --------------------------------------------------------------------------- +// FFG helpers +// --------------------------------------------------------------------------- + +/// `get_latest_message_epoch`, with gloas's modification: a gloas message is +/// dated by its slot. +fn get_latest_message_epoch(fcr_store: &FastConfirmationStore, message: &LatestMessage) -> Epoch { + match fcr_store.run.rules { + ForkRules::PreGloas => message.epoch, + ForkRules::Gloas => compute_epoch_at_slot(message.slot), + } +} + +/// `get_current_target_score`: the estimate of FFG support of the current +/// epoch's target from LMD-GHOST votes. Must be used no later than the end of +/// the current epoch. +pub fn get_current_target_score(fcr_store: &FastConfirmationStore) -> Result { + let target = get_current_target(fcr_store)?; + let balance_source = balance_source(fcr_store, BalanceKey::PulledUpHead)?; + + // The weight behind each distinct `(root, epoch)` vote, so the checkpoint + // walk below runs once per distinct vote and not once per validator. + let mut by_vote: HashMap<(Root, Epoch), Gwei> = HashMap::new(); + fcr_store + .store + .for_each_non_equivocating_latest_message(|index, message| { + let balance = balance_source.unslashed(index); + if balance != 0 { + let epoch = get_latest_message_epoch(fcr_store, &message); + let entry = by_vote.entry((message.root, epoch)).or_default(); + *entry = entry.saturating_add(balance); + } + }); + + let mut score: Gwei = 0; + for ((root, epoch), balance) in by_vote { + // A vote for a block this store no longer holds supports no target. + if let Ok(checkpoint) = get_checkpoint_for_block(fcr_store, root, epoch) + && checkpoint == target + { + score = score.saturating_add(balance); + } + } + Ok(score) +} + +/// `compute_honest_ffg_support_for_current_target`: the honest FFG support of +/// the current epoch's target, assuming [`CONFIRMATION_BYZANTINE_THRESHOLD`] +/// and synchrony, counting the votes received so far. +pub fn compute_honest_ffg_support_for_current_target( + fcr_store: &FastConfirmationStore, +) -> Result { + let current_slot = fcr_store.run.current_slot; + let current_epoch = compute_epoch_at_slot(current_slot); + let balance_source = balance_source(fcr_store, BalanceKey::PulledUpHead)?; + let total_active_balance = get_total_active_balance(&balance_source); + + // Compute FFG support for the target + let ffg_support_for_checkpoint = get_current_target_score(fcr_store)?; + + // Compute the total FFG weight up to, but excluding, the current slot + let ffg_weight_till_now = compute_committee_weight_between_slots( + fcr_store, + &balance_source, + compute_start_slot_at_epoch(current_epoch), + previous_slot(fcr_store)?, + )?; + + // Compute remaining honest FFG weight + let remaining_ffg_weight = total_active_balance + .checked_sub(ffg_weight_till_now) + .ok_or(Error::ArithmeticOverflow( + "total_active_balance - ffg_weight_till_now", + ))?; + let remaining_honest_ffg_weight = + remaining_ffg_weight / 100 * (100 - CONFIRMATION_BYZANTINE_THRESHOLD); + + // Compute potential adversarial weight + let adversarial_weight = compute_adversarial_weight( + fcr_store, + &balance_source, + compute_start_slot_at_epoch(current_epoch), + previous_slot(fcr_store)?, + )?; + + // Compute min honest FFG support + let min_honest_ffg_support = ffg_support_for_checkpoint.saturating_sub(adversarial_weight); + + Ok(min_honest_ffg_support + remaining_honest_ffg_weight) +} + +/// `will_no_conflicting_checkpoint_be_justified`: whether no checkpoint +/// conflicting with the current target can ever be justified, assuming all +/// honest validators vote for the current target from now on. +pub fn will_no_conflicting_checkpoint_be_justified( + fcr_store: &FastConfirmationStore, +) -> Result { + // If the target is unrealized justified then no conflicting checkpoint can be justified + if get_current_target(fcr_store)? == fcr_store.store.beacon_unrealized_justified_checkpoint() { + return Ok(true); + } + + let balance_source = balance_source(fcr_store, BalanceKey::PulledUpHead)?; + let total_active_balance = get_total_active_balance(&balance_source); + let honest_ffg_support = compute_honest_ffg_support_for_current_target(fcr_store)?; + Ok(3 * u128::from(honest_ffg_support) > u128::from(total_active_balance)) +} + +/// `will_current_target_be_justified`: whether the current target will +/// eventually be justified, under the same assumption. +pub fn will_current_target_be_justified(fcr_store: &FastConfirmationStore) -> Result { + let balance_source = balance_source(fcr_store, BalanceKey::PulledUpHead)?; + let total_active_balance = get_total_active_balance(&balance_source); + let honest_ffg_support = compute_honest_ffg_support_for_current_target(fcr_store)?; + Ok(3 * u128::from(honest_ffg_support) >= 2 * u128::from(total_active_balance)) +} + +// --------------------------------------------------------------------------- +// The algorithm +// --------------------------------------------------------------------------- + +/// `store.unrealized_justifications[root]`, falling back to the store's +/// justified checkpoint for a block with none recorded (see the module +/// documentation). +fn unrealized_justification(fcr_store: &FastConfirmationStore, root: Root) -> Checkpoint { + fcr_store + .store + .unrealized_justification(&root) + .unwrap_or_else(|| fcr_store.store.beacon_justified_checkpoint()) +} + +/// `fork_choice::get_voting_source` for a block of the live chain. +fn get_voting_source(fcr_store: &FastConfirmationStore, block_root: Root) -> Result { + fork_choice::get_voting_source( + fcr_store.store, + &fcr_store.run.index, + block_root, + fcr_store.config, + ) +} + +/// `update_fast_confirmation_variables`: advance the head pair and, at epoch +/// boundaries, the checkpoints. Exactly once per slot. +pub fn update_fast_confirmation_variables(fcr_store: &mut FastConfirmationStore) -> Result<()> { + // Update prev and curr slot head + fcr_store.previous_slot_head = fcr_store.current_slot_head; + fcr_store.current_slot_head = fcr_store.run.head; + + let current_slot = fcr_store.run.current_slot; + + // Update greatest unrealized justified checkpoint at the last slot of an epoch + if is_start_slot_at_epoch(current_slot + 1) { + fcr_store.previous_epoch_greatest_unrealized_checkpoint = + fcr_store.store.beacon_unrealized_justified_checkpoint(); + } + + // Update observed justified checkpoints at the start of an epoch + if is_start_slot_at_epoch(current_slot) { + fcr_store.previous_epoch_observed_justified_checkpoint = + fcr_store.current_epoch_observed_justified_checkpoint; + fcr_store.current_epoch_observed_justified_checkpoint = + fcr_store.previous_epoch_greatest_unrealized_checkpoint; + } + Ok(()) +} + +/// `find_latest_confirmed_descendant`: the most recent confirmed block in the +/// suffix of the canonical chain from `latest_confirmed_root`. +/// +/// Works only if `latest_confirmed_root` is canonical and from the previous or +/// current epoch. +pub fn find_latest_confirmed_descendant( + fcr_store: &FastConfirmationStore, + latest_confirmed_root: Root, +) -> Result { + let head = fcr_store.run.head; + let current_epoch = fcr_store.run.current_epoch; + let current_slot = fcr_store.run.current_slot; + let mut confirmed_root = latest_confirmed_root; + + let reconfirm_previous_epoch = get_block_epoch(fcr_store, confirmed_root)? + 1 == current_epoch + && get_voting_source(fcr_store, fcr_store.previous_slot_head)?.epoch + 2 >= current_epoch + && (is_start_slot_at_epoch(current_slot) + || (will_no_conflicting_checkpoint_be_justified(fcr_store)? + && (unrealized_justification(fcr_store, fcr_store.previous_slot_head).epoch + 1 + >= current_epoch + || unrealized_justification(fcr_store, head).epoch + 1 >= current_epoch))); + if reconfirm_previous_epoch { + // Get suffix of the canonical chain + let canonical_roots = get_ancestor_roots(fcr_store, head, confirmed_root)?; + + // Starting with the child of the latest_confirmed_root + // move towards the head in attempt to advance the confirmed block + // and stop when the first unconfirmed descendant is encountered + let current_balance_source = get_current_balance_source(fcr_store)?; + for block_root in canonical_roots { + let block_epoch = get_block_epoch(fcr_store, block_root)?; + + // If the current epoch is reached, exit the loop + // as this code is meant to confirm blocks from the previous epoch + if block_epoch == current_epoch { + break; + } + + // The algorithm can only rely on the previous head + // if it is a descendant of the block that is attempted to be confirmed + if !is_ancestor(fcr_store, fcr_store.previous_slot_head, block_root)? { + break; + } + + if !is_one_confirmed(fcr_store, ¤t_balance_source, block_root)? { + break; + } + + confirmed_root = block_root; + } + } + + if is_start_slot_at_epoch(current_slot) + || unrealized_justification(fcr_store, head).epoch + 1 >= current_epoch + { + // Get suffix of the canonical chain + let canonical_roots = get_ancestor_roots(fcr_store, head, confirmed_root)?; + + let mut tentative_confirmed_root = confirmed_root; + let current_balance_source = get_current_balance_source(fcr_store)?; + + for block_root in canonical_roots { + let block_epoch = get_block_epoch(fcr_store, block_root)?; + let tentative_confirmed_epoch = get_block_epoch(fcr_store, tentative_confirmed_root)?; + + // The following condition can only be true the first time + // the algorithm advances to a block from the current epoch + if block_epoch > tentative_confirmed_epoch { + // To confirm blocks from the current epoch ensure that + // current epoch target will be justified + if !will_current_target_be_justified(fcr_store)? { + break; + } + } + + if !is_one_confirmed(fcr_store, ¤t_balance_source, block_root)? { + break; + } + + tentative_confirmed_root = block_root; + } + + // The tentative_confirmed_root can only be confirmed + // if it is for sure not going to be reorged out in either the current or next epoch. + if get_block_epoch(fcr_store, tentative_confirmed_root)? == current_epoch + || (get_voting_source(fcr_store, tentative_confirmed_root)?.epoch + 2 >= current_epoch + && (is_start_slot_at_epoch(current_slot) + || will_no_conflicting_checkpoint_be_justified(fcr_store)?)) + { + confirmed_root = tentative_confirmed_root; + } + } + + Ok(confirmed_root) +} + +/// `get_latest_confirmed`: the most recent confirmed block, by executing the +/// FCR algorithm. +pub fn get_latest_confirmed(fcr_store: &FastConfirmationStore) -> Result { + let store = fcr_store.store; + let mut confirmed_root = fcr_store.confirmed_root; + let current_epoch = fcr_store.run.current_epoch; + let current_slot = fcr_store.run.current_slot; + + // Revert to finalized block if either of the following is true: + // 1) the latest confirmed block's epoch is older than the previous epoch, + // 2) the latest confirmed block does not belong to the canonical chain, + // 3) the confirmed chain starting from the current epoch observed justified checkpoint + // cannot be re-confirmed at the start of the current epoch. + let head = fcr_store.run.head; + if get_block_epoch(fcr_store, confirmed_root)? + 1 < current_epoch + || !is_ancestor(fcr_store, head, confirmed_root)? + || (is_start_slot_at_epoch(current_slot) + && !is_confirmed_chain_safe(fcr_store, confirmed_root)?) + { + confirmed_root = store.beacon_finalized_checkpoint().root; + } + + // Restart the confirmation chain if each of the following conditions are true: + // 1) it is the start of the current epoch, + // 2) epoch of fcr_store.current_epoch_observed_justified_checkpoint.root equals to the previous epoch, + // 3) fcr_store.current_epoch_observed_justified_checkpoint equals to unrealized justification of the head, + // 4) confirmed block is older than the block of fcr_store.current_epoch_observed_justified_checkpoint. + let observed = fcr_store.current_epoch_observed_justified_checkpoint; + let is_epoch_start = is_start_slot_at_epoch(current_slot); + let observed_justified_block_slot = get_block_slot(fcr_store, observed.root)?; + let is_observed_justified_block_epoch_ok = + compute_epoch_at_slot(observed_justified_block_slot) + 1 == current_epoch; + let is_head_unrealized_justified_ok = observed == unrealized_justification(fcr_store, head); + let is_confirmed_block_stale = + get_block_slot(fcr_store, confirmed_root)? < observed_justified_block_slot; + if is_epoch_start + && is_observed_justified_block_epoch_ok + && is_head_unrealized_justified_ok + && is_confirmed_block_stale + { + confirmed_root = observed.root; + } + + // Attempt to further advance the latest confirmed block + if get_block_epoch(fcr_store, confirmed_root)? + 1 >= current_epoch { + find_latest_confirmed_descendant(fcr_store, confirmed_root) + } else { + Ok(confirmed_root) + } +} + +/// `block_should_be_finalized`: whether a block is old enough that it has +/// either been finalized by now or finality is delayed. +pub fn block_should_be_finalized( + fcr_store: &FastConfirmationStore, + block_root: Root, +) -> Result { + let block_slot = get_block_slot(fcr_store, block_root)?; + let next_checkpoint_epoch = compute_epoch_at_slot(block_slot + preset::SLOTS_PER_EPOCH - 1); + let earliest_finality_slot = compute_start_slot_at_epoch(next_checkpoint_epoch + 2); + Ok(earliest_finality_slot <= fcr_store.run.current_slot) +} + +/// `get_restart_resilient_confirmed_root`, given `root_before_restart` (the +/// specification's implementation-defined `get_root_confirmed_before_restart`). +/// +/// Safe as long as synchrony has held for at least three epochs since the node +/// went offline. Call it once the node is synced and [`on_fast_confirmation`] +/// has run. +pub fn get_restart_resilient_confirmed_root( + fcr_store: &FastConfirmationStore, + root_before_restart: Root, +) -> Result { + let root_before_restart_slot = get_block_slot(fcr_store, root_before_restart)?; + + // Recently confirmed block has advanced beyond the block that was confirmed + // before the node restart + if root_before_restart_slot <= get_block_slot(fcr_store, fcr_store.confirmed_root)? { + return Ok(fcr_store.confirmed_root); + } + + // If the block is old enough it either has been finalized already or + // finality has been delayed which makes block confirmed before restart + // unreliable + if block_should_be_finalized(fcr_store, root_before_restart)? { + return Ok(fcr_store.confirmed_root); + } + + // If a block confirmed before the restart is not canonical, + // return the recently confirmed block + if !is_ancestor(fcr_store, fcr_store.run.head, root_before_restart)? { + return Ok(fcr_store.confirmed_root); + } + + Ok(root_before_restart) +} + +/// `on_fast_confirmation`: the per-slot handler. +/// +/// The call sequence is fixed by the specification: variables first, then the +/// algorithm. It must run once per slot, in the first part of the slot, after +/// attestations from past slots have been applied and before +/// `get_attestation_due_ms(epoch)` has elapsed. +pub fn on_fast_confirmation(fcr_store: &mut FastConfirmationStore) -> Result<()> { + update_fast_confirmation_variables(fcr_store)?; + fcr_store.confirmed_root = get_latest_confirmed(fcr_store)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Driver +// --------------------------------------------------------------------------- + +/// What one run reports: the most recent confirmed block and the slot the +/// run was for. The `fast_confirmation` event's three fields. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FastConfirmation { + /// Root of the most recent confirmed block. + pub block: Root, + /// That block's slot. + pub slot: Slot, + /// The slot `store.time` is in: the run's own slot. + pub current_slot: Slot, +} + +/// Run the rule once against `store`: load its variables (starting from +/// [`get_fast_confirmation_store`] if it has none), [`on_fast_confirmation`], +/// apply the restart-resilient root on this process's first run, and write +/// the variables back. +/// +/// `head` is `get_head(store).root`, which the caller has just computed. On an +/// error the stored variables are left as they were. +pub fn run_fast_confirmation( + store: &Store, + config: &Config, + head: Root, +) -> Result { + let mut fcr_store = match store.fast_confirmation() { + Some(variables) => FastConfirmationStore::new(store, config, variables, head), + None => get_fast_confirmation_store(store, config, head), + }; + on_fast_confirmation(&mut fcr_store)?; + + // Once per process, now that the node is synced and the rule has run. + if let Some(root_before_restart) = store.take_fast_confirmation_restart_root() + && block(&fcr_store, root_before_restart).is_ok() + { + fcr_store.confirmed_root = + get_restart_resilient_confirmed_root(&fcr_store, root_before_restart)?; + } + + let confirmed = FastConfirmation { + block: fcr_store.confirmed_root, + slot: get_block_slot(&fcr_store, fcr_store.confirmed_root)?, + current_slot: fcr_store.run.current_slot, + }; + store.set_fast_confirmation(fcr_store.variables()); + Ok(confirmed) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +// +// consensus-specs ships no FCR fixtures, so the scenarios of its pyspec tests +// (`tests/core/pyspec/.../phase0/fast_confirmation`) are ported here, on a +// fork-choice store built directly rather than through the state transition. +// The harness below stands in for the pyspec `FCRTest`: it fabricates blocks +// and their post-states, casts votes into the store's latest messages one slot +// late (as `on_attestation` applies them), and emulates the one thing a real +// state transition would supply, the FFG justification a given participation +// produces (see `Chain::justified_for_epoch` and `Chain::unrealized_for_block`). +// Every assertion that does not name FFG numbers is the pyspec one. +// +// Not ported, and why: +// - `test_fcr_current_epoch_*` and `test_fcr_previous_epoch_*` (130 generated +// scenarios): each drives real attestations through the state transition +// so that FFG justification, unrealized justification and late block +// inclusion fall out of them; the emulation here covers the honest-chain +// rule only, so these would test the emulation. +// - `test_ffg` (`will_*_be_justified` at exactly one and two thirds, and with +// a validator activated in the head state), `test_restart_gu` and the +// `test_revert_finality` scenarios built on late equivocations or a +// non-descendant unrealized justification: they need exact integer +// participation fractions and states with deposits and activations, which +// this harness does not build. +// - The `test_reconfirmation` and `test_variables` scenarios beyond the +// variable rotation and head tracking above, for the same reason. +// - The `test_is_one_confirmed` scenarios with large, recently activated or +// inactive validators (they need a custom registry), and the +// `..._with_empty_slot_and_attester_in_two_consecutive_slots`, +// `..._precise_committee_weight` and `compute_committee_weight_between_slots` +// vs fork-choice-weight pairs; the last are covered by the differential +// tests against a set-based definition below. +// - The gloas scenario past its first step (see its test). + +#[cfg(test)] +mod tests { + use ethlambda_storage::backend::InMemoryBackend; + + use super::*; + use crate::beacon::containers::{BeaconBlockHeader, SignedBeaconBlock, gloas, phase0}; + use crate::beacon::fork::ForkName; + use crate::beacon::fork_choice::{ + get_forkchoice_store, get_head_node, on_tick, update_checkpoints, + update_unrealized_checkpoints, + }; + use crate::beacon::helpers::test_state; + use crate::beacon::primitives::{ExecutionBlockHash, HashTreeRoot as _}; + + const SPE: u64 = preset::SLOTS_PER_EPOCH; + + /// Validators in every test chain: 32 per slot, one committee per slot on + /// either preset. + fn validators() -> usize { + (32 * SPE) as usize + } + + fn phase0_block(slot: Slot, parent_root: Root, graffiti: Root) -> SignedBeaconBlock { + SignedBeaconBlock::Phase0(phase0::SignedBeaconBlock { + message: phase0::BeaconBlock { + slot, + proposer_index: 0, + parent_root, + state_root: Root::ZERO, + body: phase0::BeaconBlockBody { + randao_reveal: Default::default(), + eth1_data: Default::default(), + graffiti, + proposer_slashings: Default::default(), + attester_slashings: Default::default(), + attestations: Default::default(), + deposits: Default::default(), + voluntary_exits: Default::default(), + }, + }, + signature: Default::default(), + }) + } + + /// An exact anchor pair, as `fork_choice`'s own tests build one: the state + /// is the block's post-state, its header naming the block. + fn anchor_pair(mut state: BeaconState) -> (BeaconState, SignedBeaconBlock) { + let parent_root = state.latest_block_header().parent_root; + let mut signed = phase0_block(state.slot(), parent_root, Root::ZERO); + let SignedBeaconBlock::Phase0(inner) = &signed else { + unreachable!("built as phase0"); + }; + *state.latest_block_header_mut() = BeaconBlockHeader { + slot: inner.message.slot, + proposer_index: inner.message.proposer_index, + parent_root, + state_root: Root::ZERO, + body_root: inner.message.body.hash_tree_root(), + }; + let state_root = state.hash_tree_root(); + let SignedBeaconBlock::Phase0(inner) = &mut signed else { + unreachable!("built as phase0"); + }; + inner.message.state_root = state_root; + (state, signed) + } + + /// The pyspec `FCRTest`, minus the state transition. + struct Chain { + store: Store, + config: Config, + /// Template for every block's post-state. + base: BeaconState, + anchor_root: Root, + anchor_slot: Slot, + /// Participation (percent of the slot's committee) per attested slot. + participation: HashMap, + /// Votes cast this slot, applied by the next tick. + pending_votes: Vec<(Vec, Root, Slot)>, + rng: u64, + } + + impl Chain { + fn new() -> Self { + let mut state = test_state::with_validators(validators()); + // Distinct shuffles per epoch, as a real chain's randao gives. + for index in 0..preset::EPOCHS_PER_HISTORICAL_VECTOR { + state.randao_mixes_mut()[index] = Root::repeat_byte(index as u8 + 1); + } + Self::from_state(state) + } + + fn from_state(state: BeaconState) -> Self { + let (anchor_state, anchor_block) = anchor_pair(state); + let anchor_slot = anchor_state.slot(); + let anchor_root = anchor_block.message_hash_tree_root(); + let config = Config::active(); + let store = get_forkchoice_store( + Arc::new(InMemoryBackend::new()), + anchor_state.clone(), + anchor_block, + &config, + ) + .expect("the pair matches"); + let mut chain = Self { + store, + config, + base: anchor_state, + anchor_root, + anchor_slot, + participation: HashMap::new(), + pending_votes: Vec::new(), + rng: 1, + }; + chain.tick(anchor_slot); + chain + } + + fn current_slot(&self) -> Slot { + fork_choice::get_current_slot(&self.store, &self.config) + } + + fn current_epoch(&self) -> Epoch { + compute_epoch_at_slot(self.current_slot()) + } + + fn head(&self) -> Root { + get_head_node(&self.store, &self.config) + .unwrap_or_else(|err| { + panic!( + "a head: {err}; has_block {} has_state {:?} justified {:?} unrealized {:?} slot {}", + self.store.has_block(&self.store.beacon_justified_checkpoint().root), + self.store.has_state(&self.store.beacon_justified_checkpoint().root), + self.store.beacon_justified_checkpoint(), + self.store.beacon_unrealized_justified_checkpoint(), + self.current_slot() + ) + }) + .root + } + + fn tick(&mut self, slot: Slot) { + on_tick( + &mut self.store, + slot * self.config.seconds_per_slot, + &self.config, + ); + } + + /// A throwaway FCR store over the current variables. + fn fcr(&self) -> FastConfirmationStore<'_> { + let variables = self.store.fast_confirmation().unwrap_or_else(|| { + FastConfirmationVariables::at_finalized(self.store.beacon_finalized_checkpoint()) + }); + FastConfirmationStore::new(&self.store, &self.config, variables, self.head()) + } + + fn committee(&self, slot: Slot) -> Vec { + get_slot_committee(&self.fcr(), slot) + .expect("committee") + .to_vec() + } + + fn next_random(&mut self) -> u64 { + self.rng ^= self.rng << 13; + self.rng ^= self.rng >> 7; + self.rng ^= self.rng << 17; + self.rng + } + + fn sample( + &mut self, + mut committee: Vec, + percent: u64, + ) -> Vec { + let count = committee.len() * percent as usize / 100; + for i in (1..committee.len()).rev() { + let j = (self.next_random() % (i as u64 + 1)) as usize; + committee.swap(i, j); + } + committee.truncate(count); + committee + } + + // -- FFG emulation --------------------------------------------------- + + fn epoch_start_root(&self, tip: Root, epoch: Epoch) -> Root { + let index = self.store.block_index(); + fork_choice::get_ancestor(&index, tip, compute_start_slot_at_epoch(epoch)) + .expect("indexed") + } + + /// Whether `epoch`'s votes, over all of its slots, reach two thirds. + fn justified_by_end_of(&self, epoch: Epoch) -> bool { + let start = compute_start_slot_at_epoch(epoch); + let sum: u64 = (start..start + SPE) + .map(|slot| self.participation.get(&slot).copied().unwrap_or(0)) + .sum(); + 3 * sum >= 200 * SPE + } + + /// The `current_justified_checkpoint` a state of `epoch` on `tip`'s + /// chain carries: the latest epoch before it whose votes reached two + /// thirds, else the anchor's. + fn justified_for_epoch(&self, tip: Root, epoch: Epoch) -> Checkpoint { + let anchor_epoch = compute_epoch_at_slot(self.anchor_slot); + for justified in (anchor_epoch..epoch).rev() { + if self.justified_by_end_of(justified) { + return Checkpoint { + epoch: justified, + root: self.epoch_start_root(tip, justified), + }; + } + } + Checkpoint { + epoch: anchor_epoch, + root: self.anchor_root, + } + } + + /// The unrealized justification of a block at `slot` on `parent`: + /// its own epoch's target once the votes the block can include + /// (earlier slots of the epoch) reach two thirds, else what its state + /// has justified. + fn unrealized_for_block(&self, parent: Root, slot: Slot) -> Checkpoint { + let epoch = compute_epoch_at_slot(slot); + let start = compute_start_slot_at_epoch(epoch); + let sum: u64 = (start..slot) + .map(|slot| self.participation.get(&slot).copied().unwrap_or(0)) + .sum(); + if slot > start && 3 * sum >= 200 * SPE { + Checkpoint { + epoch, + root: self.epoch_start_root(parent, epoch), + } + } else { + self.justified_for_epoch(parent, epoch) + } + } + + // -- Chain building -------------------------------------------------- + + fn add_block_with(&mut self, parent: Root, slot: Slot, graffiti: Root) -> Root { + let block = phase0_block(slot, parent, graffiti); + let root = block.message_hash_tree_root(); + let epoch = compute_epoch_at_slot(slot); + let justified = self.justified_for_epoch(parent, epoch); + let unrealized = self.unrealized_for_block(parent, slot); + let mut state = self.base.clone(); + *state.slot_mut() = slot; + *state.current_justified_checkpoint_mut() = justified; + // The store diffs a state against the one its own header names as + // the parent, so the header has to be the block's. + *state.latest_block_header_mut() = BeaconBlockHeader { + slot, + proposer_index: 0, + parent_root: parent, + state_root: Root::ZERO, + body_root: Root::ZERO, + }; + self.store.insert_signed_block(root, block).expect("insert"); + self.store.insert_state(root, state).expect("insert"); + self.store + .set_unrealized_justification(root, slot, unrealized); + let finalized_unrealized = self.store.beacon_unrealized_finalized_checkpoint(); + update_unrealized_checkpoints(&mut self.store, unrealized, finalized_unrealized); + let finalized = self.store.beacon_finalized_checkpoint(); + update_checkpoints(&mut self.store, justified, finalized); + root + } + + fn add_block(&mut self, parent: Root, slot: Slot) -> Root { + self.add_block_with(parent, slot, Root::ZERO) + } + + /// Cast `indices`' votes for `root` at `slot`, applied by the next tick. + fn vote(&mut self, indices: Vec, root: Root, slot: Slot) { + self.pending_votes.push((indices, root, slot)); + } + + /// `FCRTest.attest`: `percent` of `slot`'s committee votes for `root`. + fn attest(&mut self, root: Root, slot: Slot, percent: u64) { + self.participation.insert(slot, percent); + if percent == 0 { + return; + } + let committee = self.committee(slot); + let attesters = self.sample(committee, percent); + self.vote(attesters, root, slot); + } + + fn apply_votes(&mut self) { + for (indices, root, slot) in std::mem::take(&mut self.pending_votes) { + for index in indices { + let newer = self + .store + .latest_message(index) + .is_none_or(|message| message.slot < slot); + if newer { + self.store.set_latest_message( + index, + LatestMessage { + epoch: compute_epoch_at_slot(slot), + slot, + root, + payload_present: false, + }, + ); + } + } + } + } + + fn next_slot(&mut self) { + let slot = self.current_slot() + 1; + self.tick(slot); + self.apply_votes(); + } + + fn run_fcr(&mut self) -> FastConfirmation { + let head = self.head(); + run_fast_confirmation(&self.store, &self.config, head).expect("the rule runs") + } + + fn confirmed_root(&self) -> Root { + self.store.fast_confirmed_root().expect("the rule has run") + } + + /// `next_slot_with_block`: a block on `parent` (default the head) at + /// the current slot, attested by `percent` of the slot's committee, + /// then the next slot. The anchor's own slot gets no block. + fn next_slot_with_block_on(&mut self, parent: Option, percent: u64) -> Root { + let slot = self.current_slot(); + let root = if slot == self.anchor_slot { + self.anchor_root + } else { + let parent = parent.unwrap_or_else(|| self.head()); + self.add_block(parent, slot) + }; + let head = self.head(); + self.attest(head, slot, percent); + self.next_slot(); + root + } + + fn next_slot_with_block_and_fcr(&mut self, percent: u64) -> Root { + let root = self.next_slot_with_block_on(None, percent); + self.run_fcr(); + root + } + + fn next_slot_with_block_on_and_fcr(&mut self, parent: Root, percent: u64) -> Root { + let root = self.next_slot_with_block_on(Some(parent), percent); + self.run_fcr(); + root + } + + fn run_slots(&mut self, slots: u64, percent: u64) { + for _ in 0..slots { + self.next_slot_with_block_and_fcr(percent); + } + } + + /// `attest_and_next_slot_with_fast_confirmation`. + fn attest_and_next_slot_with_fcr(&mut self, root: Option, percent: u64) { + let slot = self.current_slot(); + let root = root.unwrap_or_else(|| self.head()); + self.attest(root, slot, percent); + self.next_slot(); + self.run_fcr(); + } + + fn source(&self) -> Arc { + get_current_balance_source(&self.fcr()).expect("balance source") + } + + fn is_one_confirmed(&self, root: Root) -> bool { + let fcr = self.fcr(); + let source = get_current_balance_source(&fcr).expect("balance source"); + is_one_confirmed(&fcr, &source, root).expect("is_one_confirmed") + } + + fn parent_slot(&self, root: Root) -> Slot { + let (_, parent) = self.store.block_entry(&root).expect("known"); + self.store.block_entry(&parent).expect("known parent").0 + } + + fn slot_of(&self, root: Root) -> Slot { + self.store.block_entry(&root).expect("known").0 + } + } + + /// The first block of a chain run through epoch 1 with full participation, + /// where every `is_one_confirmed` scenario starts ("build through epoch 1 + /// to establish the balance source"). + fn chain_through_epoch_one() -> Chain { + let mut chain = Chain::new(); + chain.run_slots(2 * SPE, 100); + chain + } + + // -- test_basic ---------------------------------------------------------- + + #[test] + fn fast_confirm_an_epoch() { + let mut chain = Chain::new(); + for _ in 0..SPE { + chain.next_slot_with_block_and_fcr(100); + // Ensure head is confirmed + assert_eq!(chain.confirmed_root(), chain.head()); + } + } + + #[test] + fn fast_confirm_with_low_participation() { + let mut chain = Chain::new(); + for _ in 0..3 * SPE { + chain.next_slot_with_block_and_fcr(90); + // Ensure confirmed block is being advanced + let confirmed_epoch = get_block_epoch(&chain.fcr(), chain.confirmed_root()).unwrap(); + assert!(confirmed_epoch + 1 >= chain.current_epoch()); + } + } + + #[test] + fn fast_confirm_with_asynchrony_at_epoch_boundary() { + let mut chain = Chain::new(); + let first_epoch_start = chain.anchor_slot; + + // Move closer to epoch boundary + while chain.current_slot() < first_epoch_start + SPE - 2 { + chain.next_slot_with_block_and_fcr(100); + assert_eq!(chain.confirmed_root(), chain.head()); + } + + // Run for a few slots with asynchrony + while chain.current_slot() < first_epoch_start + SPE + 2 { + chain.next_slot_with_block_and_fcr(75); + // Confirmed block is behind the head + assert_ne!(chain.confirmed_root(), chain.head()); + } + + // Restore participation and run for an epoch + for _ in 0..SPE { + chain.next_slot_with_block_and_fcr(100); + let confirmed_epoch = get_block_epoch(&chain.fcr(), chain.confirmed_root()).unwrap(); + assert!(confirmed_epoch + 1 >= chain.current_epoch()); + } + + // After asynchrony followed by a period of good participation FCR provides 1-slot delay + chain.next_slot_with_block_and_fcr(100); + assert_eq!(chain.confirmed_root(), chain.head()); + } + + // -- test_is_one_confirmed --------------------------------------------- + + #[test] + fn is_one_confirmed_passes_with_full_participation() { + let mut chain = chain_through_epoch_one(); + let block_b = chain.next_slot_with_block_and_fcr(100); + + let parent_slot = chain.parent_slot(block_b); + assert_eq!(parent_slot + 1, chain.slot_of(block_b), "consecutive slots"); + + assert!(chain.is_one_confirmed(block_b)); + + // Inspect the individual terms of the inequality + let fcr = chain.fcr(); + let source = chain.source(); + let current_slot = chain.current_slot(); + let support = get_attestation_score(&fcr, &source, block_b); + let proposer_score = compute_proposer_score(&fcr, &source); + let maximum_support = compute_committee_weight_between_slots( + &fcr, + &source, + parent_slot + 1, + current_slot - 1, + ) + .unwrap(); + let support_discount = get_support_discount(&fcr, &source, block_b).unwrap(); + let adversarial_weight = get_adversarial_weight(&fcr, &source, block_b).unwrap(); + + // No empty slots, so the discount must be 0 + assert_eq!(support_discount, 0); + let lhs = 2 * support + support_discount; + let rhs = maximum_support + proposer_score + 2 * adversarial_weight; + assert!(lhs > rhs, "inequality failed: lhs={lhs} vs rhs={rhs}"); + } + + #[test] + fn is_one_confirmed_fails_with_low_participation() { + let mut chain = chain_through_epoch_one(); + let block_b = chain.next_slot_with_block_and_fcr(50); + assert_eq!(chain.parent_slot(block_b) + 1, chain.slot_of(block_b)); + + assert!(!chain.is_one_confirmed(block_b)); + + let fcr = chain.fcr(); + let source = chain.source(); + let maximum_support = compute_committee_weight_between_slots( + &fcr, + &source, + chain.slot_of(block_b), + chain.current_slot() - 1, + ) + .unwrap(); + // Half the committee cannot outweigh the boost and the adversary. + assert!(get_attestation_score(&fcr, &source, block_b) <= maximum_support / 2); + } + + #[test] + fn is_one_confirmed_support_accumulates_over_slots() { + let mut chain = chain_through_epoch_one(); + let block_b = chain.next_slot_with_block_and_fcr(88); + + // One slot of support: the boost is too large + assert!(!chain.is_one_confirmed(block_b)); + + // A second slot of support dilutes it + chain.attest_and_next_slot_with_fcr(Some(block_b), 88); + assert!(chain.is_one_confirmed(block_b)); + } + + #[test] + fn is_one_confirmed_slashing_non_supporters_helps() { + let mut chain = chain_through_epoch_one(); + let block_b = chain.next_slot_with_block_and_fcr(88); + let slot = chain.slot_of(block_b); + + let support_before; + let adversarial_weight_before; + { + let fcr = chain.fcr(); + let source = chain.source(); + support_before = get_attestation_score(&fcr, &source, block_b); + adversarial_weight_before = get_adversarial_weight(&fcr, &source, block_b).unwrap(); + } + assert!(!chain.is_one_confirmed(block_b), "precondition"); + + // Validators whose latest message is not for B + let non_supporters: Vec<_> = chain + .committee(slot) + .into_iter() + .filter(|&index| { + chain + .store + .latest_message(index) + .is_none_or(|message| message.root != block_b) + }) + .collect(); + assert!(!non_supporters.is_empty(), "need non-supporters to slash"); + for index in non_supporters { + chain.store.insert_equivocating_index(index); + } + + let fcr = chain.fcr(); + let source = chain.source(); + // Support unchanged: only non-voters were slashed + assert_eq!( + get_attestation_score(&fcr, &source, block_b), + support_before + ); + // Adversarial budget decreased + assert!( + get_adversarial_weight(&fcr, &source, block_b).unwrap() < adversarial_weight_before + ); + // Slashing non-supporters helps + assert!(is_one_confirmed(&fcr, &source, block_b).unwrap()); + } + + #[test] + fn is_one_confirmed_slashing_supporters_does_not_hurt() { + let mut chain = chain_through_epoch_one(); + let block_b = chain.next_slot_with_block_and_fcr(100); + let slot = chain.slot_of(block_b); + assert!(chain.is_one_confirmed(block_b)); + + let (support_before, adversarial_before) = { + let fcr = chain.fcr(); + let source = chain.source(); + ( + get_attestation_score(&fcr, &source, block_b), + get_adversarial_weight(&fcr, &source, block_b).unwrap(), + ) + }; + // One supporter equivocates: its vote leaves the support, and its + // weight leaves the adversarial budget by the same amount. + let supporter = chain.committee(slot)[0]; + chain.store.insert_equivocating_index(supporter); + + let fcr = chain.fcr(); + let source = chain.source(); + let balance = source.unslashed(supporter); + assert_eq!( + get_attestation_score(&fcr, &source, block_b), + support_before - balance + ); + assert_eq!( + get_adversarial_weight(&fcr, &source, block_b).unwrap(), + adversarial_before.saturating_sub(balance) + ); + assert!(is_one_confirmed(&fcr, &source, block_b).unwrap()); + } + + #[test] + fn is_one_confirmed_empty_slot_discount() { + let mut chain = chain_through_epoch_one(); + + // Block A: consecutive slot, no discount + let block_a = chain.next_slot_with_block_and_fcr(100); + { + let fcr = chain.fcr(); + let source = chain.source(); + assert_eq!(get_support_discount(&fcr, &source, block_a).unwrap(), 0); + } + assert!(chain.is_one_confirmed(block_a)); + + // Block B: after an empty slot + let head_before_empty = chain.head(); + chain.attest_and_next_slot_with_fcr(Some(head_before_empty), 100); + let block_b = chain.next_slot_with_block_on_and_fcr(head_before_empty, 100); + + let parent_slot = chain.parent_slot(block_b); + let block_slot = chain.slot_of(block_b); + assert!(parent_slot + 1 < block_slot, "an empty slot gap"); + + { + let fcr = chain.fcr(); + let source = chain.source(); + let discount = get_support_discount(&fcr, &source, block_b).unwrap(); + assert!(discount > 0); + + // discount = parent_support_in_empty_slots - adversarial_weight_in_empty_slots + let parent_node = ForkChoiceNode { + root: head_before_empty, + payload_status: PayloadStatus::Full, + }; + let parent_support = get_node_support_between_slots( + &fcr, + &source, + parent_node, + parent_slot + 1, + block_slot - 1, + ) + .unwrap(); + let adversarial = + compute_adversarial_weight(&fcr, &source, parent_slot + 1, block_slot - 1).unwrap(); + assert_eq!(discount, parent_support - adversarial); + } + + // One more slot of support, and B passes with the discount contributing + chain.attest_and_next_slot_with_fcr(Some(block_b), 100); + assert!(chain.is_one_confirmed(block_b)); + let fcr = chain.fcr(); + let source = chain.source(); + let discount = get_support_discount(&fcr, &source, block_b).unwrap(); + assert!(discount > 0, "the discount is still contributing"); + } + + #[test] + fn is_one_confirmed_epoch_crossing_block() { + let mut chain = Chain::new(); + // Most of epoch 1, to its last slot + chain.run_slots(SPE - 1, 100); + let parent_root = chain.head(); + + // Skip the first slot of epoch 2, then propose across the boundary + chain.attest_and_next_slot_with_fcr(Some(parent_root), 100); + let block_b = chain.next_slot_with_block_on_and_fcr(parent_root, 100); + + let fcr = chain.fcr(); + let block_epoch = get_block_epoch(&fcr, block_b).unwrap(); + assert!(block_epoch > compute_epoch_at_slot(chain.parent_slot(block_b))); + + let source = chain.source(); + let current_slot = chain.current_slot(); + let epoch_start = compute_start_slot_at_epoch(block_epoch); + let from_epoch_start = + compute_adversarial_weight(&fcr, &source, epoch_start, current_slot - 1).unwrap(); + let from_block_slot = + compute_adversarial_weight(&fcr, &source, chain.slot_of(block_b), current_slot - 1) + .unwrap(); + // A crossing block's adversarial range starts at its epoch's first slot + assert_eq!( + get_adversarial_weight(&fcr, &source, block_b).unwrap(), + from_epoch_start + ); + assert!(from_epoch_start >= from_block_slot); + } + + #[test] + fn is_one_confirmed_fails_with_competing_branch() { + let mut chain = chain_through_epoch_one(); + let fork_point = chain.head(); + let slot = chain.current_slot(); + // Two siblings at one slot; the committee splits between them + let a = chain.add_block_with(fork_point, slot, Root::repeat_byte(0xaa)); + let b = chain.add_block_with(fork_point, slot, Root::repeat_byte(0xbb)); + let committee = chain.committee(slot); + let (voters_a, voters_b) = committee.split_at(committee.len() / 2); + chain.vote(voters_a.to_vec(), a, slot); + chain.vote(voters_b.to_vec(), b, slot); + chain.participation.insert(slot, 100); + chain.next_slot(); + + assert!(!chain.is_one_confirmed(a)); + assert!(!chain.is_one_confirmed(b)); + } + + #[test] + fn is_one_confirmed_is_false_for_an_optimistic_block() { + let mut chain = chain_through_epoch_one(); + let block_b = chain.next_slot_with_block_and_fcr(100); + assert!(chain.is_one_confirmed(block_b)); + let slot = chain.slot_of(block_b); + chain.store.insert_beacon_optimistic_root(block_b, slot); + assert!(!chain.is_one_confirmed(block_b)); + } + + #[test] + fn is_confirmed_chain_safe_passes_full_chain() { + let mut chain = Chain::new(); + // Up to the first slot of epoch 2 + chain.run_slots(SPE, 100); + assert_eq!(chain.current_slot(), compute_start_slot_at_epoch(2)); + let fcr = chain.fcr(); + assert!(is_confirmed_chain_safe(&fcr, chain.confirmed_root()).unwrap()); + } + + // -- the cached sums against the specification's own definitions ------- + + /// `compute_committee_weight_between_slots`, written the way the + /// specification writes it: a set of participants per range. + fn naive_committee_weight( + fcr: &FastConfirmationStore, + source: &BalanceSource, + start_slot: Slot, + end_slot: Slot, + ) -> Gwei { + if start_slot > end_slot { + return 0; + } + if is_full_validator_set_covered(start_slot, end_slot) { + return get_total_active_balance(source); + } + let mut participants = HashSet::new(); + for slot in start_slot..=end_slot { + participants.extend(get_slot_committee(fcr, slot).unwrap().iter().copied()); + } + total_balance( + participants + .into_iter() + .map(|index| source.active(index)) + .sum(), + ) + } + + #[test] + fn the_cached_sums_match_the_specifications_set_based_definitions() { + let mut chain = Chain::new(); + chain.run_slots(2 * SPE + 3, 90); + for index in (0..40).step_by(3) { + chain.store.insert_equivocating_index(index); + } + let fcr = chain.fcr(); + let source = chain.source(); + let end_limit = chain.current_slot() - 1; + let start_limit = chain.anchor_slot; + + for start in start_limit..=end_limit { + for end in start..=end_limit { + assert_eq!( + compute_committee_weight_between_slots(&fcr, &source, start, end).unwrap(), + naive_committee_weight(&fcr, &source, start, end), + "weight over [{start}, {end}]" + ); + + let mut committee = HashSet::new(); + for slot in start..=end { + committee.extend(get_slot_committee(&fcr, slot).unwrap().iter().copied()); + } + let naive_equivocation: Gwei = committee + .iter() + .filter(|index| chain.store.is_equivocating(**index)) + .map(|&index| source.active(index)) + .sum(); + assert_eq!( + get_equivocation_score(&fcr, &source, start, end).unwrap(), + naive_equivocation, + "equivocation score over [{start}, {end}]" + ); + } + } + } + + #[test] + fn the_score_fold_matches_the_specifications_per_block_attestation_score() { + let mut chain = Chain::new(); + chain.run_slots(SPE + 4, 80); + // A fork whose votes split the committee + let fork_point = chain.head(); + let slot = chain.current_slot(); + let a = chain.add_block_with(fork_point, slot, Root::repeat_byte(1)); + let b = chain.add_block_with(fork_point, slot, Root::repeat_byte(2)); + let committee = chain.committee(slot); + let (voters_a, voters_b) = committee.split_at(committee.len() / 3); + chain.vote(voters_a.to_vec(), a, slot); + chain.vote(voters_b.to_vec(), b, slot); + chain.next_slot(); + chain.store.insert_equivocating_index(voters_a[0]); + + let fcr = chain.fcr(); + let source = chain.source(); + let index = chain.store.block_index(); + let state = fork_choice::checkpoint_state( + &chain.store, + &fcr.current_epoch_observed_justified_checkpoint, + &chain.config, + ) + .unwrap(); + for &root in index.keys() { + assert_eq!( + get_attestation_score(&fcr, &source, root), + fork_choice::get_attestation_score(&chain.store, &index, root, &state).unwrap(), + "score of {root}" + ); + } + } + + // -- helpers -------------------------------------------------------------- + + #[test] + fn a_full_epoch_is_covered_and_a_partial_one_is_not() { + assert!(is_full_validator_set_covered(0, SPE - 1)); + assert!(is_full_validator_set_covered(SPE, 2 * SPE - 1)); + assert!(!is_full_validator_set_covered(1, SPE - 1)); + assert!(!is_full_validator_set_covered(1, SPE)); + assert!(is_full_validator_set_covered(1, 2 * SPE - 1)); + } + + #[test] + fn ancestor_roots_run_oldest_first_and_stop_before_the_terminal() { + let mut chain = Chain::new(); + chain.run_slots(4, 100); + let head = chain.head(); + let fcr = chain.fcr(); + let roots = get_ancestor_roots(&fcr, head, chain.anchor_root).unwrap(); + assert_eq!(roots.len(), 3, "the anchor is excluded"); + assert_eq!(*roots.last().unwrap(), head); + let slots: Vec<_> = roots.iter().map(|&root| chain.slot_of(root)).collect(); + assert!(slots.windows(2).all(|pair| pair[0] < pair[1])); + // The block itself, or a terminal off its chain, gives nothing + assert!(get_ancestor_roots(&fcr, head, head).unwrap().is_empty()); + let fork = chain.add_block_with( + chain.anchor_root, + chain.anchor_slot + 1, + Root::repeat_byte(9), + ); + let fcr = chain.fcr(); + assert!(get_ancestor_roots(&fcr, head, fork).unwrap().is_empty()); + } + + // -- test_variables ------------------------------------------------------- + + #[test] + fn slot_head_variables_are_updated_every_slot() { + let mut chain = Chain::new(); + let mut previous_head = None; + for _ in 0..4 { + chain.next_slot_with_block_and_fcr(100); + let variables = chain.store.fast_confirmation().unwrap(); + assert_eq!(variables.current_slot_head, chain.head()); + if let Some(previous_head) = previous_head { + assert_eq!(variables.previous_slot_head, previous_head); + } + previous_head = Some(variables.current_slot_head); + } + } + + #[test] + fn observed_justified_checkpoints_rotate_at_the_start_of_an_epoch() { + let mut chain = Chain::new(); + // To the last slot of epoch 1: the greatest unrealized checkpoint is + // sampled by the run there. + chain.run_slots(SPE - 1, 100); + assert_eq!(chain.current_slot() + 1, compute_start_slot_at_epoch(2)); + let before = chain.store.fast_confirmation().unwrap(); + assert_eq!( + before.previous_epoch_greatest_unrealized_checkpoint, + chain.store.beacon_unrealized_justified_checkpoint() + ); + + // The first slot of epoch 2 rotates them + chain.next_slot_with_block_and_fcr(100); + let after = chain.store.fast_confirmation().unwrap(); + assert_eq!( + after.previous_epoch_observed_justified_checkpoint, + before.current_epoch_observed_justified_checkpoint + ); + assert_eq!( + after.current_epoch_observed_justified_checkpoint, + before.previous_epoch_greatest_unrealized_checkpoint + ); + + // And nothing else moves them until the next boundary + chain.next_slot_with_block_and_fcr(100); + let later = chain.store.fast_confirmation().unwrap(); + assert_eq!( + later.current_epoch_observed_justified_checkpoint, + after.current_epoch_observed_justified_checkpoint + ); + assert_eq!( + later.previous_epoch_observed_justified_checkpoint, + after.previous_epoch_observed_justified_checkpoint + ); + } + + #[test] + fn the_variables_start_at_the_finalized_checkpoint() { + let chain = Chain::new(); + let finalized = chain.store.beacon_finalized_checkpoint(); + let fcr = get_fast_confirmation_store(&chain.store, &chain.config, chain.head()); + assert_eq!(fcr.confirmed_root, finalized.root); + assert_eq!(fcr.previous_epoch_observed_justified_checkpoint, finalized); + assert_eq!(fcr.current_epoch_observed_justified_checkpoint, finalized); + assert_eq!(fcr.previous_epoch_greatest_unrealized_checkpoint, finalized); + assert_eq!(fcr.previous_slot_head, finalized.root); + assert_eq!(fcr.current_slot_head, finalized.root); + assert_eq!(chain.store.fast_confirmed_root(), None, "nothing has run"); + } + + // -- test_empty_slots / test_revert_finality ---------------------------- + + #[test] + fn fcr_handles_a_single_empty_slot() { + let mut chain = Chain::new(); + chain.run_slots(SPE / 2, 100); + // An empty slot: everyone attests to the head, no block + chain.attest_and_next_slot_with_fcr(None, 100); + let head = chain.head(); + // Blocks resume after the gap and are confirmed again + for _ in 0..4 { + chain.next_slot_with_block_and_fcr(100); + } + assert_ne!(chain.head(), head); + assert_eq!(chain.confirmed_root(), chain.head()); + } + + #[test] + fn fcr_reverts_to_finalized_when_confirmed_is_not_canonical_mid_epoch() { + let mut chain = Chain::new(); + chain.run_slots(SPE / 2, 100); + let confirmed = chain.confirmed_root(); + assert_eq!(confirmed, chain.head()); + let finalized = chain.store.beacon_finalized_checkpoint().root; + + // A branch from the finalized block that every validator moves to + let slot = chain.current_slot(); + let branch = chain.add_block_with(finalized, slot, Root::repeat_byte(0xee)); + let everyone: Vec<_> = (0..validators() as u64).collect(); + chain.vote(everyone, branch, slot); + chain.next_slot(); + assert_eq!(chain.head(), branch, "the heavier branch is the head"); + + let confirmation = chain.run_fcr(); + // The old confirmed block is off the canonical chain, so the rule + // fell back to finalized; whatever it confirmed since is on the branch. + let fcr = chain.fcr(); + assert!(is_ancestor(&fcr, branch, confirmation.block).unwrap()); + assert_ne!(confirmation.block, confirmed); + } + + // -- restart resilience --------------------------------------------------- + + #[test] + fn the_restart_resilient_root_follows_the_specifications_four_cases() { + let mut chain = Chain::new(); + chain.run_slots(SPE / 2, 100); + let head = chain.head(); + let fcr = chain.fcr(); + let anchor = chain.anchor_root; + + // The recently confirmed block is past the one confirmed before the restart + assert_eq!( + get_restart_resilient_confirmed_root(&fcr, anchor).unwrap(), + fcr.confirmed_root + ); + + // A confirmed block that has fallen behind: the rule keeps the recent one + let mut behind = chain.fcr(); + behind.confirmed_root = anchor; + let mid = chain + .store + .block_entry(&head) + .map(|(_, parent)| parent) + .unwrap(); + assert_eq!( + get_restart_resilient_confirmed_root(&behind, mid).unwrap(), + mid, + "a canonical block ahead of the recent one, not yet due to be final" + ); + + // A block confirmed before the restart that is not canonical + let fork = chain.add_block_with(anchor, chain.anchor_slot + 1, Root::repeat_byte(7)); + let mut behind = chain.fcr(); + behind.confirmed_root = anchor; + assert_eq!( + get_restart_resilient_confirmed_root(&behind, fork).unwrap(), + anchor + ); + } + + #[test] + fn a_block_should_be_finalized_two_epochs_after_its_checkpoint() { + let mut chain = Chain::new(); + chain.run_slots(SPE / 2, 100); + let block = chain.head(); + let fcr = chain.fcr(); + assert!(!block_should_be_finalized(&fcr, block).unwrap()); + + // Far enough ahead that finality has had its two epochs + let block_slot = chain.slot_of(block); + let due = compute_start_slot_at_epoch(compute_epoch_at_slot(block_slot + SPE - 1) + 2); + chain.tick(due); + let fcr = chain.fcr(); + assert!(block_should_be_finalized(&fcr, block).unwrap()); + } + + /// A restarted process reads the confirmed root its predecessor persisted + /// once, on its first synced run, and keeps it when it is canonical, recent + /// and ahead of what the run itself confirms. + #[test] + fn the_first_synced_run_applies_the_persisted_root_once() { + let mut chain = Chain::new(); + // A chain nobody attests to, so the run itself confirms nothing + // beyond the finalized anchor. + for _ in 0..SPE / 2 { + chain.next_slot_with_block_on(None, 0); + } + let head = chain.head(); + let (_, parent) = chain.store.block_entry(&head).unwrap(); + let before_restart = parent; + assert_ne!(before_restart, chain.anchor_root); + + // What the previous process left behind + chain + .store + .set_fast_confirmation(FastConfirmationVariables::at_finalized(Checkpoint { + epoch: 0, + root: before_restart, + })); + chain.store.reset_fast_confirmation(); + + let confirmation = chain.run_fcr(); + assert_eq!(confirmation.block, before_restart, "restored"); + + // Offered once: the next run starts from what this one confirmed + assert_eq!(chain.store.take_fast_confirmation_restart_root(), None); + } + + #[test] + fn without_a_persisted_root_an_unconfirmable_chain_stays_at_the_anchor() { + let mut chain = Chain::new(); + for _ in 0..SPE / 2 { + chain.next_slot_with_block_on(None, 0); + } + assert_eq!(chain.run_fcr().block, chain.anchor_root); + } + + // -- gloas --------------------------------------------------------------- + + fn gloas_block( + slot: Slot, + parent_root: Root, + parent_block_hash: ExecutionBlockHash, + block_hash: ExecutionBlockHash, + ) -> SignedBeaconBlock { + SignedBeaconBlock::Gloas(gloas::SignedBeaconBlock { + message: gloas::BeaconBlock { + slot, + proposer_index: 0, + parent_root, + state_root: Root::ZERO, + body: gloas::BeaconBlockBody { + signed_execution_payload_bid: gloas::SignedExecutionPayloadBid { + message: gloas::ExecutionPayloadBid { + parent_block_hash, + block_hash, + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + }, + signature: Default::default(), + }) + } + + /// The gloas `test_is_one_confirmed_delayed_with_mixed_payload_votes_in_empty_slot` + /// scenario, down to the term it exercises: after an empty slot, only the + /// votes for the parent's payload branch that B builds on (FULL) count as + /// parent support in the empty slot (the discount's first term), where + /// pre-gloas every vote for the parent does. The scenario's later + /// accumulation steps are not ported (no gloas chain builder here). + #[test] + fn gloas_empty_slot_support_counts_only_the_payload_branch_the_block_builds_on() { + let config = Config::active().with_fork_epoch(ForkName::Gloas, 1); + let gloas_start = compute_start_slot_at_epoch(1); + let anchor_root = Root::repeat_byte(0xa0); + let anchor = Checkpoint { + epoch: 0, + root: anchor_root, + }; + let mut store = Store::init_beacon( + Arc::new(InMemoryBackend::new()), + 0, + config.clone(), + anchor_root, + Store::beacon_checkpoint_as_stored(anchor), + 0, + ); + let hash = |byte| ExecutionBlockHash::repeat_byte(byte); + let state = test_state::with_validators_at(ForkName::Gloas, validators()); + + // anchor -> parent (slot s) -> [empty slot] -> block (slot s + 2), + // block building on the parent's FULL payload. + let parent_root = Root::repeat_byte(0xb0); + let block_root = Root::repeat_byte(0xc0); + let (parent_slot, block_slot) = (gloas_start, gloas_start + 2); + for (root, signed) in [ + ( + anchor_root, + gloas_block(0, Root::ZERO, ExecutionBlockHash::ZERO, hash(1)), + ), + ( + parent_root, + gloas_block(parent_slot, anchor_root, hash(1), hash(2)), + ), + ( + block_root, + gloas_block(block_slot, parent_root, hash(2), hash(3)), + ), + ] { + store.insert_signed_block(root, signed).expect("insert"); + let mut post = state.clone(); + *post.slot_mut() = store.block_entry(&root).unwrap().0.max(gloas_start); + store.insert_state(root, post).expect("insert state"); + } + store + .set_time_ms((block_slot + 1) * config.slot_duration_ms) + .unwrap(); + + // The empty slot's committee splits: half vote for the parent with + // its payload, half without. + let empty_slot = parent_slot + 1; + let committee = { + let variables = FastConfirmationVariables::at_finalized(anchor); + let fcr = FastConfirmationStore::new(&store, &config, variables, block_root); + get_slot_committee(&fcr, empty_slot).unwrap().to_vec() + }; + let (full_voters, empty_voters) = committee.split_at(committee.len() / 2); + for (voters, payload_present) in [(full_voters, true), (empty_voters, false)] { + for &index in voters { + store.set_latest_message( + index, + LatestMessage { + epoch: 1, + slot: empty_slot, + root: parent_root, + payload_present, + }, + ); + } + } + + let discount_under = |config: &Config| { + let variables = FastConfirmationVariables::at_finalized(anchor); + let fcr = FastConfirmationStore::new(&store, config, variables, block_root); + let source = balance_source(&fcr, BalanceKey::of(anchor)).unwrap(); + let support = get_node_support_between_slots( + &fcr, + &source, + match fcr.run.rules { + ForkRules::Gloas => fork_choice::gloas_get_ancestor( + &store, + get_node_for_root(&fcr, block_root), + parent_slot, + ) + .unwrap(), + ForkRules::PreGloas => ForkChoiceNode { + root: parent_root, + payload_status: PayloadStatus::Full, + }, + }, + empty_slot, + empty_slot, + ) + .unwrap(); + (fcr.run.rules, support) + }; + + let (rules, gloas_support) = discount_under(&config); + assert_eq!(rules, ForkRules::Gloas); + let (rules, pre_gloas_support) = discount_under(&Config::active()); + assert_eq!(rules, ForkRules::PreGloas); + + let state_for_weights = { + let variables = FastConfirmationVariables::at_finalized(anchor); + let fcr = FastConfirmationStore::new(&store, &config, variables, block_root); + balance_source(&fcr, BalanceKey::of(anchor)).unwrap() + }; + let full_weight: Gwei = full_voters + .iter() + .map(|&index| state_for_weights.unslashed(index)) + .sum(); + let all_weight: Gwei = committee + .iter() + .map(|&index| state_for_weights.unslashed(index)) + .sum(); + assert_eq!(gloas_support, full_weight, "only FULL votes"); + assert_eq!(pre_gloas_support, all_weight, "every vote for the parent"); + assert!(gloas_support < pre_gloas_support); + } + + #[test] + fn a_gloas_node_for_a_root_is_pending_and_a_pre_gloas_one_is_full() { + let chain = Chain::new(); + let fcr = chain.fcr(); + assert_eq!( + get_node_for_root(&fcr, chain.anchor_root).payload_status, + PayloadStatus::Full + ); + let gloas_config = Config::active().with_fork_epoch(ForkName::Gloas, 0); + let variables = + FastConfirmationVariables::at_finalized(chain.store.beacon_finalized_checkpoint()); + let fcr = FastConfirmationStore::new(&chain.store, &gloas_config, variables, chain.head()); + assert_eq!( + get_node_for_root(&fcr, chain.anchor_root).payload_status, + PayloadStatus::Pending + ); + } +} diff --git a/crates/blockchain/state_transition/src/beacon/mod.rs b/crates/blockchain/state_transition/src/beacon/mod.rs index 22ecdc718..770835b41 100644 --- a/crates/blockchain/state_transition/src/beacon/mod.rs +++ b/crates/blockchain/state_transition/src/beacon/mod.rs @@ -78,6 +78,7 @@ pub use ethlambda_crypto::bls; mod bls_fixtures; pub mod das; pub mod data_columns; +pub mod fast_confirmation; pub mod fork_choice; pub mod genesis; pub mod gloas_block_production; diff --git a/crates/common/types/src/beacon/fork_choice.rs b/crates/common/types/src/beacon/fork_choice.rs index d5318e2e1..9901d53f3 100644 --- a/crates/common/types/src/beacon/fork_choice.rs +++ b/crates/common/types/src/beacon/fork_choice.rs @@ -209,6 +209,46 @@ pub struct PayloadStatusV1 { pub validation_error: Option, } +/// The variables the Fast Confirmation Rule carries between slots: every field +/// of consensus-specs' `FastConfirmationStore` except its read-only `store`. +/// +/// Held in the `ethlambda_storage::Store` (beside the rest of the beacon +/// fork-choice scratch), hence here rather than in `ethlambda-state-transition`, +/// which owns the algorithm and builds a `FastConfirmationStore` around one of +/// these for each run. `ethlambda-storage` cannot depend on that crate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FastConfirmationVariables { + /// Root of the most recent confirmed block. + pub confirmed_root: Root, + /// A justified checkpoint observed by all honest nodes at the beginning of + /// the previous epoch, assuming synchrony. + pub previous_epoch_observed_justified_checkpoint: Checkpoint, + /// The same, at the beginning of the current epoch. + pub current_epoch_observed_justified_checkpoint: Checkpoint, + /// The greatest unrealized justified checkpoint at the start of the last + /// slot of the previous epoch, in the local view. + pub previous_epoch_greatest_unrealized_checkpoint: Checkpoint, + /// The head at the start of the previous slot. + pub previous_slot_head: Root, + /// The head at the start of the current slot. + pub current_slot_head: Root, +} + +impl FastConfirmationVariables { + /// `get_fast_confirmation_store`'s values: conservatively the finalized + /// checkpoint for every variable. + pub fn at_finalized(finalized: Checkpoint) -> Self { + Self { + confirmed_root: finalized.root, + previous_epoch_observed_justified_checkpoint: finalized, + current_epoch_observed_justified_checkpoint: finalized, + previous_epoch_greatest_unrealized_checkpoint: finalized, + previous_slot_head: finalized.root, + current_slot_head: finalized.root, + } + } +} + /// 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. diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 924dcaba6..c6e20f02f 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -30,8 +30,8 @@ use ethlambda_types::{ }, fork::ForkName, fork_choice::{ - BlockPayloadLink, ForkChoiceNode, JustifiedBalances, LatestMessage, PayloadStatus, - PayloadStatusEnum, PayloadStatusV1, PowBlock, + BlockPayloadLink, FastConfirmationVariables, ForkChoiceNode, JustifiedBalances, + LatestMessage, PayloadStatus, PayloadStatusEnum, PayloadStatusV1, PowBlock, }, preset::{PTC_SIZE, Preset, SLOTS_PER_EPOCH}, primitives::ExecutionBlockHash, @@ -170,6 +170,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 root the Fast Confirmation Rule last confirmed, the one piece of its +/// state worth keeping across a restart. Its value has type [`H256`] and it's +/// SSZ-encoded; absent means no run has confirmed anything yet. +/// +/// Read once per process, by [`Store::take_fast_confirmation_restart_root`], +/// for the specification's `get_restart_resilient_confirmed_root`; rewritten +/// whenever a run's confirmed root changes. Adding it did not bump +/// [`DB_VERSION`]: an older directory simply has no row. +const KEY_BEACON_FAST_CONFIRMED_ROOT: &[u8] = b"beacon_fast_confirmed_root"; /// 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 @@ -821,6 +830,16 @@ pub(crate) struct BeaconScratch { /// from `checkpoint_states`, so nothing needs to persist it. pub(crate) justified_balances: JustifiedBalancesCache, pub(crate) pow_blocks: HashMap, + /// The Fast Confirmation Rule's variables. `None` until the first run of + /// this process (and again after a run is skipped for being too far behind + /// the clock), which then starts from `get_fast_confirmation_store`'s + /// conservative values. Only `confirmed_root` is persisted (see + /// [`KEY_BEACON_FAST_CONFIRMED_ROOT`]); the rest is rebuilt within an + /// epoch, as the specification intends for a restarted node. + pub(crate) fast_confirmation: Option, + /// Whether this process has already offered the persisted confirmed root + /// to the restart-resilience rule, so it is offered at most once. + pub(crate) fast_confirmation_restart_root_taken: bool, /// In-memory half of `Table::BeaconUnrealizedJustifications`; see this /// struct's own documentation for why this map, unlike its neighbours, /// has a persisted backing table. @@ -4789,6 +4808,74 @@ impl Store { .retain(|_, (slot, _)| *slot >= finalized_slot); } + // ============ Fast Confirmation Rule ============ + + /// The Fast Confirmation Rule's variables, or `None` before this process + /// has run it (or after a skipped run reset it). + pub fn fast_confirmation(&self) -> Option { + self.beacon.lock().unwrap().fast_confirmation + } + + /// The most recent confirmed block's root, or `None` before the first run. + /// + /// What `forkchoiceUpdated`'s safe block is read from: callers fall back + /// to the finalized root, which is where the rule's own initialization + /// puts `confirmed_root`. + pub fn fast_confirmed_root(&self) -> Option { + self.fast_confirmation() + .map(|variables| variables.confirmed_root) + } + + /// Records the rule's variables after a run, persisting `confirmed_root` + /// when it changed. + pub fn set_fast_confirmation(&self, variables: FastConfirmationVariables) { + let previous = self + .beacon + .lock() + .unwrap() + .fast_confirmation + .replace(variables); + if previous.map(|previous| previous.confirmed_root) != Some(variables.confirmed_root) { + self.set_metadata(KEY_BEACON_FAST_CONFIRMED_ROOT, &variables.confirmed_root); + } + } + + /// Forgets the rule's variables, so the next run starts from the + /// finalized checkpoint. For a run skipped because the node is far behind + /// the clock: the stored heads would be stale then, and may be pruned. + pub fn reset_fast_confirmation(&self) { + self.beacon.lock().unwrap().fast_confirmation = None; + } + + /// The confirmed root the previous process persisted, handed out once per + /// process (`None` after the first call, and on a fresh directory). + /// + /// The specification's implementation-defined + /// `get_root_confirmed_before_restart`. Taken before the first run writes + /// its own confirmed root over the row. + pub fn take_fast_confirmation_restart_root(&self) -> Option { + { + let mut scratch = self.beacon.lock().unwrap(); + if scratch.fast_confirmation_restart_root_taken { + return None; + } + scratch.fast_confirmation_restart_root_taken = true; + } + let view = self.backend.begin_read().expect("read view"); + view.read_with(Table::Metadata, KEY_BEACON_FAST_CONFIRMED_ROOT, |bytes| { + H256::from_ssz_bytes(bytes).expect("valid encoding") + }) + .expect("read") + } + + /// Every validator index observed equivocating, as one snapshot. + /// + /// For a reader that needs the whole set and would otherwise take this + /// store's lock once per validator through [`Store::is_equivocating`]. + pub fn equivocating_indices(&self) -> HashSet { + self.beacon.lock().unwrap().equivocating_indices.clone() + } + /// Returns `root`'s unrealized justification, if this store has computed /// one. /// diff --git a/docs/beacon_engine.md b/docs/beacon_engine.md index 333e70a04..eec2e7fca 100644 --- a/docs/beacon_engine.md +++ b/docs/beacon_engine.md @@ -248,14 +248,16 @@ per tick, not once per block. It is sent even when nothing moved: an execution client doing state sync needs to keep being fed a recent head or its sync cannot converge. -Its three hashes come from the cached execution hash of the head, the justified -checkpoint's root and the finalized checkpoint's root. A root with no cached hash -contributes the zero hash, which is a meaningful value here rather than an -absence: EIP-3675 requires `finalized_block_hash` to be zero before a -post-transition block is finalized, and the specification's own -`get_safe_execution_block_hash` returns zero when no payload is justified yet. A -follower whose *head* has no cached hash is still sitting on its checkpoint -anchor and sends nothing at all. +Its three hashes come from the cached execution hash of the head, the root of +the block the Fast Confirmation Rule last confirmed (see +[`beacon_stf.md`](beacon_stf.md#fast-confirmation-rule)) and the finalized +checkpoint's root. The rule's confirmed root starts at the finalized block and +is updated once per slot, in the same tick, before this call. A root with no +cached hash contributes the zero hash, which is a meaningful value here rather +than an absence: EIP-3675 requires `finalized_block_hash` to be zero before a +post-transition block is finalized, and a confirmed block before the merge has +no payload. A follower whose *head* has no cached hash is still sitting on its +checkpoint anchor and sends nothing at all. The cache is pruned to the unfinalized window on every tick and every import, with the finalized checkpoint's own root exempted by name. The slot bound alone @@ -268,9 +270,9 @@ execution client advancing its own finalized block for the life of the process. For a gloas head the hashes come from bids rather than from a cached payload hash: the head node's `bid.block_hash` when it is FULL and its `bid.parent_block_hash` when it is EMPTY, the finalized block's -`bid.parent_block_hash`, and the justified block's `bid.parent_block_hash` as -the safe hash. The last is a fallback, since the specification builds the safe -hash on fast confirmation, which the follower does not run. The call is V4 with +`bid.parent_block_hash`, and the confirmed block's `bid.parent_block_hash` as +the safe hash (the specification's gloas `get_safe_execution_block_hash`: only +the parent payload of a confirmed beacon block is safe). The call is V4 with the node's custody columns. A head whose payload status has not been computed yet (the first moments after a restart) sends nothing. diff --git a/docs/beacon_stf.md b/docs/beacon_stf.md index b2df5a0f2..96640edf0 100644 --- a/docs/beacon_stf.md +++ b/docs/beacon_stf.md @@ -549,6 +549,42 @@ 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). +## Fast Confirmation Rule + +`beacon::fast_confirmation` implements the Fast Confirmation Rule (FCR) of +consensus-specs' `phase0`, `bellatrix` and `gloas` `fast-confirmation.md`, at +**consensus-specs `master`, commit `f993cf2`** rather than the pinned +`v1.7.0-beta.2`. The release ships no FCR fixtures, and `master` has fixes it +lacks (the gloas empty-slot support discount, precise committee weight, the +restart-resilient confirmed root, the `support_discount <= maximum_support` +assertion). A later bump should diff those three files against that commit. + +- **Shape.** One Rust function per specification function, named as the + specification names it. `FastConfirmationStore` is the specification's own + store: a borrowed fork-choice `Store` plus the six variables. The variables + live in the `Store` between runs (`Store::fast_confirmation`, beside the rest + of the beacon fork-choice scratch); only `confirmed_root` is persisted. +- **When it runs.** Once per slot, in the beacon actor's tick, after the clock + advance, the deferred aggregates and the head recompute (so past slots' + attestations are applied) and before `forkchoiceUpdated`, whose safe block it + decides. Not on imports, which recompute the head many times a slot. It is + skipped, and its variables reset to the finalized checkpoint, while the head + is more than 32 slots behind the wall clock (the `head` event's gate): the + rule assumes synchrony from the start of the previous slot. +- **Outputs.** The `fast_confirmation` Beacon API event, after every run, and + the engine's safe block hash (`get_safe_execution_block_hash`). +- **Cost.** One run is `O(V + M + B log B)`: a pass over each of at most three + balance sources' registries (`V` validators), one bottom-up fold of the latest + messages (`M`) into every block's attestation score, and a sort of the live + chain (`B` blocks), plus committee-range work that is a sum over cached slot + weights except for ranges that cross an epoch boundary without covering an + epoch. `lean_beacon_fast_confirmation_time_seconds` times a run. +- **Restart.** The confirmed root is persisted when it changes, and the first + synced run of a process offers it to `get_restart_resilient_confirmed_root`. +- **Tests.** No fixtures exist; the pyspec scenarios are ported as unit tests in + the module, on a store built directly (see the comment above them for what + they emulate and which scenarios are not ported). + ## Macros and traits Two `macro_rules!` in the whole crate, both local, both replacing boilerplate that diff --git a/docs/rpc.md b/docs/rpc.md index bdcc260ec..8f12cacec 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -879,13 +879,14 @@ these are ever sent: | `chain_reorg` | `{"slot":"200", "depth":"50", "old_head_block":"0x…", "new_head_block":"0x…", "old_head_state":"0x…", "new_head_state":"0x…", "epoch":"2", "execution_optimistic":false}` | The new head does not descend from the previous one | | `attestation` | the aggregate's `Attestation` | An aggregate passed the `beacon_aggregate_and_proof` rules: accepted from gossip, or submitted through `POST /eth/v2/validator/aggregate_and_proofs` | | `data_column_sidecar` | `{"block_root":"0x…", "index":"1", "slot":"1"}` | A column this node custodies is stored, from any source | +| `fast_confirmation` | `{"block":"0x…", "slot":"1", "current_slot":"2"}` | The Fast Confirmation Rule ran, once per slot at the slot's tick, whether or not the confirmed block changed. `block` and `slot` are the most recent confirmed block's, `current_slot` the slot of the run. Not sent while the head is more than 32 slots behind the wall clock (the rule is not run then) | Never emitted: `single_attestation` (subnet votes are verified and relayed but never reach the chain actor), the operation topics (`voluntary_exit`, `proposer_slashing`, `attester_slashing`, `bls_to_execution_change`, `contribution_and_proof`: no operation pools), `payload_attributes`, the light -client topics, `blob_sidecar`, and `head_v2` with every other gloas topic (this -build stops at fulu). +client topics, `blob_sidecar`, and `head_v2` with every other gloas topic that +has no producer here. How head and finality events behave: diff --git a/docs/spec_deviations.md b/docs/spec_deviations.md index bdcbdf779..618df115a 100644 --- a/docs/spec_deviations.md +++ b/docs/spec_deviations.md @@ -596,10 +596,13 @@ node, and a pre-gloas block's is its parent. Both walks stop at finality. been computed yet is skipped. - `finalized_block_hash` is the finalized block's `bid.parent_block_hash`, as the specification has it. -- `safe_block_hash` is the justified block's `bid.parent_block_hash`. The - specification's `get_safe_execution_block_hash` is built on fast - confirmation, which the follower does not run, so this is the fallback. It - can only be older than the confirmed block, which keeps it safe. +- `safe_block_hash` is the Fast Confirmation Rule's confirmed block's + `bid.parent_block_hash`, the specification's gloas + `get_safe_execution_block_hash`. (It used to be the justified block's, a + stand-in from before the rule was implemented; there is no deviation left + here.) Before the rule's first run, or while the node is far behind the clock + and the rule is not run, the confirmed root is the finalized block, which is + what the rule's own initialization sets it to. - A pre-gloas checkpoint block, including one beneath a gloas head, keeps its own payload hash. - The call is V4 with the node's custody columns when the head block is gloas, From c2fd35fd0c46f832e1f83000e0b212c58282406f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:32:40 -0300 Subject: [PATCH 2/2] perf(beacon): run the fast confirmation rule off the fork choice's caches On mainnet a block (c2302e2b, slot 15587649) took 826 ms to import, 766 ms of it queued in the chain actor's mailbox behind the slot tick, whose FCR run took 1-2 s. The rule runs on the actor, so it has to cost about what the head computation does. It now reads what fork choice already keeps: - get_current_target_score skips latest messages from other epochs, which can never vote for the current target. Resolving their checkpoints walked roots below the live chain index, decoding a block per parent hop; that walk was the >1 s tail. - Balance sources come from the store's per-checkpoint JustifiedBalances, which now also carry the active-but-slashed validators and the total active balance, instead of a registry pass per source per run. The pulled-up head reads the current target's entry unless the head state shows a slashing processed in the current epoch, and then builds from the pulled-up state as before: activity and effective balances are fixed within an epoch, and slashings[epoch % EPOCHS_PER_SLASHINGS_VECTOR] starts every epoch at zero. - Attestation scores are the proto-array's PENDING subtree totals (#775) when the source is the justified checkpoint's, which it is on every run but an epoch's first; the remaining fold hashes roots with foldhash. - Committee weights are memoized per (source, slot), and a range across an epoch boundary reuses the later part's members, shared by every range ending at the same slot; equivocators are a bitmap. At 1M validators (the ignored fast_confirmation_timing bench), per run: mid-epoch ~27 ms -> ~0.5 ms (get_head: ~80 us); an epoch's first slot 113 ms -> 14.7 ms (get_head: 12.9 ms); its second and third ~40 ms -> 2.3-2.8 ms. New tests compare the cached scores with the fold, pre-gloas and under gloas; the filtered target score with the unfiltered sum; and the reused pulled-up balances with the pulled-up state's, with and without a slashing in the epoch. Claude-Session: https://claude.ai/code/session_011vghNmndKPkZinHRkVTwVB --- CLAUDE.md | 8 +- Cargo.lock | 1 + crates/blockchain/state_transition/Cargo.toml | 5 + .../src/beacon/fast_confirmation.rs | 879 +++++++++++++++--- .../src/beacon/fork_choice.rs | 52 +- crates/common/types/src/beacon/fork_choice.rs | 74 ++ crates/storage/src/proto_array.rs | 2 + crates/storage/src/store.rs | 2 + docs/beacon_stf.md | 37 +- 9 files changed, 925 insertions(+), 135 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 25721d565..8d53b1e11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -865,7 +865,13 @@ transitions are in `ethlambda-types`, per the section above. Nothing above actor's tick after the head recompute and before `forkchoiceUpdated`; its confirmed root is the engine's safe hash and feeds the `fast_confirmation` event. State lives in `Store` (`fast_confirmation`), per-run sums are shared - in a `RunContext`. See [`docs/beacon_stf.md`](docs/beacon_stf.md#fast-confirmation-rule). + in a `RunContext`. It runs on the actor, so it must stay about as cheap as + `get_head`: it reads the fork choice's caches rather than the registry or + every vote (balance sources from the per-checkpoint `JustifiedBalances`, + attestation scores from the proto-array when the source is the justified + checkpoint's), and `get_current_target_score` walks only current-epoch votes + (an older vote's root may be below the live index, where each parent hop + decodes a block). See [`docs/beacon_stf.md`](docs/beacon_stf.md#fast-confirmation-rule). - Nothing is ignored for being unimplemented in the state transition or fork choice. Ignored cases are the `LightClient*` containers (a different layer, out of scope), `networking/gossip_*` cases outside what the node validates diff --git a/Cargo.lock b/Cargo.lock index 7588eeed5..3d06e3227 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2190,6 +2190,7 @@ dependencies = [ "ethlambda-storage", "ethlambda-test-fixtures", "ethlambda-types", + "foldhash 0.2.0", "hex", "libssz 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=5cb14378ffdadcb51b387c003567e2630d0c7073)", "libssz-derive 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=5cb14378ffdadcb51b387c003567e2630d0c7073)", diff --git a/crates/blockchain/state_transition/Cargo.toml b/crates/blockchain/state_transition/Cargo.toml index 7b819c538..22f009203 100644 --- a/crates/blockchain/state_transition/Cargo.toml +++ b/crates/blockchain/state_transition/Cargo.toml @@ -87,6 +87,11 @@ c-kzg = { version = "2.1.8", features = ["ethereum_kzg_settings"] } # functions that c-kzg does not export. num-bigint = "0.4.6" +# The Fast Confirmation Rule's per-vote maps, keyed by block root: one lookup +# per validator's latest message, where SipHash was most of the pass. The +# version `ethlambda-storage` already uses for its own block-root maps. +foldhash = "0.2.0" + [dev-dependencies] ethlambda-test-fixtures.workspace = true serde.workspace = true diff --git a/crates/blockchain/state_transition/src/beacon/fast_confirmation.rs b/crates/blockchain/state_transition/src/beacon/fast_confirmation.rs index ce03dc164..2567d20d5 100644 --- a/crates/blockchain/state_transition/src/beacon/fast_confirmation.rs +++ b/crates/blockchain/state_transition/src/beacon/fast_confirmation.rs @@ -29,27 +29,37 @@ //! `compute_committee_weight_between_slots` and `get_attestation_score` each //! walk the validator registry or the committees, and they are called once //! per block of the confirmed chain. On mainnet (about a million active -//! validators) that would be tens of registry passes per slot, so the -//! per-run work is shared instead, in a [`RunContext`] built with the -//! [`FastConfirmationStore`] and dropped with it: +//! validators) that would be tens of registry passes per slot. The rule runs +//! on the chain actor at every slot tick, right after the head computation, +//! so it reuses what the fork choice already keeps across slots, and shares +//! the rest in a [`RunContext`] built with the [`FastConfirmationStore`] and +//! dropped with it: //! -//! - one pass over each balance source's registry ([`BalanceSource`]), at most -//! three per run (previous and current observed justified checkpoints, and -//! the pulled-up head state), shared by every later read; -//! - one bottom-up fold over the latest messages per balance source gives -//! every block's `get_attestation_score` ([`attestation_scores`]), as -//! `compute_weights` does for the head, instead of one scan per block; +//! - balance sources ([`BalanceSource`]) are the store's per-checkpoint +//! balances, the snapshot the head reads its justified balances from: a +//! registry pass once per checkpoint, usually paid when its boundary block +//! was imported, not once per source per run. The pulled-up head reads the +//! current target's entry, which is exact while the current epoch holds no +//! slashing (see [`pulled_up_head_balances`]); +//! - every block's `get_attestation_score` ([`attestation_scores`]) is read +//! from the fork choice's cached vote weights when the source is the +//! justified checkpoint's, which it is on every run but the first of an +//! epoch; otherwise (the previous epoch's source, at an epoch's first slot) +//! one bottom-up fold over the latest messages gives every block's score; //! - one sum per `(source, slot)` of a slot's committee weight, so a range -//! inside one epoch is a sum over slots; a range crossing an epoch boundary -//! without covering an epoch is the only case that deduplicates validators, -//! and it is the smaller ranges; +//! inside one epoch is a sum over slots, and a range across an epoch +//! boundary sums its earlier epoch's slots less the members its later part +//! already counted, shared by every range ending at the same slot; +//! equivocators are a bitmap, collected once per slot; //! - committees come from the store's shared committee cache. //! -//! A run is therefore `O(V + M + B log B)` for `V` validators (the registry -//! passes), `M` latest messages and `B` unfinalized blocks, plus -//! `O(committee range)` for the few epoch-crossing or empty-slot ranges and -//! `O(votes * depth)` only in [`get_current_target_score`], which resolves -//! one checkpoint per distinct `(root, epoch)` vote rather than per vote. +//! A run is therefore `O(B + C)` for `B` unfinalized blocks and `C` committee +//! members in the slots its ranges touch, plus `O(M)` latest messages on the +//! runs that fold (an epoch's first slot) or reach +//! [`get_current_target_score`] (its first few), which resolves one +//! checkpoint per distinct root voted for in the current epoch, never one for +//! a vote from another epoch. `fast_confirmation_timing` measures it beside +//! the head computation on a million validators. //! //! # Where the Rust departs from the Python //! @@ -69,11 +79,12 @@ //! persisted ([`Store::take_fast_confirmation_restart_root`]). use std::cell::{OnceCell, RefCell}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Arc; -use ethlambda_storage::Store; +use ethlambda_storage::{Store, WeightRules}; pub use ethlambda_types::beacon::fork_choice::FastConfirmationVariables; +use ethlambda_types::beacon::fork_choice::JustifiedBalances; use crate::beacon::committees::EpochCommittees; use crate::beacon::config::Config; @@ -84,7 +95,6 @@ use crate::beacon::fork_choice::{ }; use crate::beacon::helpers::accessors::{CommitteeCacheExt, get_current_epoch}; use crate::beacon::helpers::misc::{compute_epoch_at_slot, compute_start_slot_at_epoch}; -use crate::beacon::helpers::predicates::is_active_validator; use crate::beacon::preset; use crate::beacon::primitives::{Epoch, Gwei, Root, Slot, ValidatorIndex}; @@ -170,6 +180,15 @@ impl<'a> FastConfirmationStore<'a> { } } +/// A map keyed by block root, hashed with foldhash rather than SipHash: the +/// passes over the latest messages look one up per validator, about a million +/// lookups on mainnet, where SipHash was most of the pass. +type RootMap = HashMap; + +/// [`slot_committee_weight`]'s memo key: the balance source, the slot, and the +/// later slot range whose committee members the weight leaves out. +type SlotWeightKey = (BalanceKey, Slot, Option<(Slot, Slot)>); + /// Which state a [`BalanceSource`] was built from, which is also its memo key. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum BalanceKey { @@ -185,61 +204,48 @@ impl BalanceKey { } } -/// A registry flattened for the rule's sums, built in one pass over a state's -/// validators. +/// A balance source's registry, flattened for the rule's sums. /// /// Every sum the specification takes over a balance source is a sum of /// effective balances over validators that are active at the state's epoch, /// and, for most, unslashed. A validator that fails the condition contributes -/// zero, so the two arrays below are all a sum needs, and "is this validator -/// in the set" never has to be asked on its own: an active validator with a -/// zero effective balance contributes the same zero either way. +/// zero, so two per-validator figures are all a sum needs, and "is this +/// validator in the set" never has to be asked on its own: an active validator +/// with a zero effective balance contributes the same zero either way. +/// +/// Both figures come from the store's per-checkpoint balances cache, the one +/// the head computation reads its justified balances from +/// ([`fork_choice::checkpoint_balances`]): the unslashed figure is exactly +/// `get_weight`'s vote weight, and the snapshot carries the active set's +/// figures beside it. A balance source therefore costs a registry pass once +/// per checkpoint, usually already paid when its boundary block was imported, +/// rather than once per source per run, which was a third of every run. pub struct BalanceSource { key: BalanceKey, - /// Effective balance if active at the state's epoch, else zero (slashed - /// validators included). - active: Vec, - /// Effective balance if active and not slashed, else zero. - unslashed: Vec, - /// The sum of `active`, before `get_total_balance`'s floor. - total_active: Gwei, - /// The sum of `unslashed`, before `get_total_balance`'s floor. - total_unslashed: Gwei, + balances: Arc, } impl BalanceSource { - fn build(key: BalanceKey, state: &BeaconState) -> Self { - let epoch = get_current_epoch(state); - let mut active = Vec::with_capacity(state.validator_count()); - let mut unslashed = Vec::with_capacity(state.validator_count()); - let (mut total_active, mut total_unslashed) = (0u64, 0u64); - for validator in state.iter_validators() { - let balance = if is_active_validator(validator, epoch) { - validator.effective_balance - } else { - 0 - }; - let unslashed_balance = if validator.slashed { 0 } else { balance }; - total_active = total_active.saturating_add(balance); - total_unslashed = total_unslashed.saturating_add(unslashed_balance); - active.push(balance); - unslashed.push(unslashed_balance); - } - Self { - key, - active, - unslashed, - total_active, - total_unslashed, - } - } - + /// Effective balance if active at the state's epoch, else zero (slashed + /// validators included). fn active(&self, index: ValidatorIndex) -> Gwei { - self.active.get(index as usize).copied().unwrap_or(0) + self.balances.active_balance(index) } + /// Effective balance if active and not slashed, else zero. fn unslashed(&self, index: ValidatorIndex) -> Gwei { - self.unslashed.get(index as usize).copied().unwrap_or(0) + self.balances.get(index) + } + + /// The sum of [`Self::active`], before `get_total_balance`'s floor. + fn total_active(&self) -> Gwei { + self.balances.total_active_balance() + } + + /// The sum of [`Self::unslashed`], already floored as `get_total_balance` + /// floors it. + fn total_unslashed(&self) -> Gwei { + self.balances.total_balance() } } @@ -253,16 +259,24 @@ struct RunContext { /// `root -> (slot, parent_root)` of the live chain. index: HashMap, /// `store.equivocating_indices`, as one snapshot. - equivocating: HashSet, + equivocating: ValidatorSet, head_state: OnceCell>, pulled_up_head_state: OnceCell>, committees: RefCell>>, slot_committees: RefCell>>>, + /// [`committee_members`], by slot range. + committee_members: RefCell>>, + /// [`slot_equivocators`], by slot. + slot_equivocators: RefCell>>>, sources: RefCell>>, - scores: RefCell>>>, - slot_weights: RefCell>, + scores: RefCell>>>, + /// [`slot_committee_weight`], by source, slot and the later range it + /// leaves out. + slot_weights: RefCell>, range_weights: RefCell>, equivocation_scores: RefCell>, + current_target_score: OnceCell, + honest_ffg_support: OnceCell, } impl RunContext { @@ -275,16 +289,20 @@ impl RunContext { current_epoch, rules: ForkRules::of(config.fork_at_epoch(current_epoch)), index: store.block_index(), - equivocating: store.equivocating_indices(), + equivocating: store.equivocating_indices().into_iter().collect(), head_state: OnceCell::new(), pulled_up_head_state: OnceCell::new(), committees: RefCell::default(), slot_committees: RefCell::default(), + committee_members: RefCell::default(), + slot_equivocators: RefCell::default(), sources: RefCell::default(), scores: RefCell::default(), slot_weights: RefCell::default(), range_weights: RefCell::default(), equivocation_scores: RefCell::default(), + current_target_score: OnceCell::new(), + honest_ffg_support: OnceCell::new(), } } } @@ -475,9 +493,12 @@ pub fn get_slot_committee( return Ok(Arc::clone(participants)); } let committees = epoch_committees(fcr_store, compute_epoch_at_slot(slot))?; - let mut participants = Vec::new(); - for index in 0..committees.committees_per_slot() { - participants.extend_from_slice(committees.committee(slot, index)?); + let slot_committees = (0..committees.committees_per_slot()) + .map(|index| committees.committee(slot, index)) + .collect::>>()?; + let mut participants = Vec::with_capacity(slot_committees.iter().map(|c| c.len()).sum()); + for committee in slot_committees { + participants.extend_from_slice(committee); } let participants = Arc::new(participants); fcr_store @@ -547,7 +568,7 @@ pub fn get_pulled_up_head_state(fcr_store: &FastConfirmationStore) -> Result fork_choice::checkpoint_state( + let balances = match key { + BalanceKey::Checkpoint(epoch, root) => fork_choice::checkpoint_balances( fcr_store.store, &Checkpoint { epoch, root }, fcr_store.config, )?, - BalanceKey::PulledUpHead => get_pulled_up_head_state(fcr_store)?, + BalanceKey::PulledUpHead => pulled_up_head_balances(fcr_store)?, }; - let source = Arc::new(BalanceSource::build(key, &state)); + let source = Arc::new(BalanceSource { key, balances }); fcr_store .run .sources @@ -572,6 +593,48 @@ fn balance_source( Ok(source) } +/// The balances of [`get_pulled_up_head_state`], from the per-checkpoint +/// cache whenever they are the current target's. +/// +/// The pulled-up head state is `checkpoint_state((current_epoch, head))`: the +/// head's own state, advanced to the epoch's first slot if the head is from an +/// earlier epoch. In that case, and when the head is the epoch's first block, +/// it *is* the current target's checkpoint state, since the target is the +/// head's ancestor at the epoch's first slot. Otherwise the two differ by the +/// current epoch's blocks on the head's chain, and none of what those blocks +/// can do to the registry moves a figure a balance source holds, except a +/// slashing: +/// +/// - activity at the current epoch is fixed at its start: a block can only set +/// an exit epoch past the next one (an exit, a slashing, an exit request), +/// and a validator a pre-electra deposit appends is not yet active; +/// - effective balances change only in epoch processing. +/// +/// A slashing zeroes the validator's unslashed figure, and adds its effective +/// balance to `state.slashings[current_epoch % EPOCHS_PER_SLASHINGS_VECTOR]`, +/// which epoch processing reset to zero on the way into the epoch. While that +/// entry of the head's state is still zero, the target's cached balances are +/// exactly the pulled-up head's; when it is not, which is rare, they are built +/// from the state itself and kept for this run only, since a state that is not +/// a checkpoint's has no place in the per-checkpoint cache. +fn pulled_up_head_balances(fcr_store: &FastConfirmationStore) -> Result> { + let target = get_current_target(fcr_store)?; + let head = fcr_store.run.head; + let epoch = fcr_store.run.current_epoch; + let head_state = head_state(fcr_store)?; + let slashed_this_epoch = target.root != head + && get_current_epoch(&head_state) == epoch + && head_state.slashings()[epoch as usize % preset::EPOCHS_PER_SLASHINGS_VECTOR] != 0; + if !slashed_this_epoch { + return fork_choice::checkpoint_balances(fcr_store.store, &target, fcr_store.config); + } + let state = get_pulled_up_head_state(fcr_store)?; + let pulled_up = Checkpoint { epoch, root: head }; + Ok(Arc::new(fork_choice::build_justified_balances( + pulled_up, &state, + ))) +} + /// `get_previous_balance_source`: the state of the previous epoch's observed /// justified checkpoint. fn get_previous_balance_source(fcr_store: &FastConfirmationStore) -> Result> { @@ -598,7 +661,7 @@ fn total_balance(sum: Gwei) -> Gwei { /// `get_total_active_balance(balance_source)`. fn get_total_active_balance(balance_source: &BalanceSource) -> Gwei { - total_balance(balance_source.total_active) + total_balance(balance_source.total_active()) } /// `compute_proposer_score(balance_source)`. @@ -607,7 +670,7 @@ fn compute_proposer_score( balance_source: &BalanceSource, ) -> Gwei { fork_choice::committee_fraction( - total_balance(balance_source.total_unslashed), + total_balance(balance_source.total_unslashed()), fcr_store.config.proposer_score_boost, ) } @@ -659,7 +722,7 @@ pub fn get_node_support_between_slots( let mut support: Gwei = 0; for index in participants_between_slots(fcr_store, start_slot, end_slot)? { let balance = balance_source.unslashed(index); - if balance == 0 || fcr_store.run.equivocating.contains(&index) { + if balance == 0 || fcr_store.run.equivocating.contains(index) { continue; } let Some(message) = fcr_store.store.latest_message(index) else { @@ -680,25 +743,115 @@ pub fn is_full_validator_set_covered(start_slot: Slot, end_slot: Slot) -> bool { start_full_epoch < end_full_epoch } -/// The weight of one slot's committees in `balance_source`, memoized. +/// The weight of one slot's committee members in `balance_source`, leaving +/// out, when `later` names a slot range, the members that also sit in a +/// committee of that range. Memoized for the run. +/// +/// The leaving out is what a range crossing an epoch boundary needs: a +/// validator sits in one committee per epoch, so within an epoch the slots' +/// committees are disjoint and their weights add up, but across the boundary +/// a validator can sit in both epochs' parts of the range. Every range that +/// crosses the same boundary up to the same slot leaves out the same later +/// part, so the per-slot figures it builds from are shared between those +/// ranges, where building each range's participants anew paid for the whole +/// range every time. fn slot_committee_weight( fcr_store: &FastConfirmationStore, balance_source: &BalanceSource, slot: Slot, + later: Option<(Slot, Slot)>, ) -> Result { - let key = (balance_source.key, slot); + let key = (balance_source.key, slot, later); if let Some(weight) = fcr_store.run.slot_weights.borrow().get(&key) { return Ok(*weight); } - let weight = get_slot_committee(fcr_store, slot)? - .iter() - .fold(0, |sum: Gwei, &index| { + let committee = get_slot_committee(fcr_store, slot)?; + let weight = match later { + None => committee.iter().fold(0, |sum: Gwei, &index| { sum.saturating_add(balance_source.active(index)) - }); + }), + Some((later_start, later_end)) => { + let counted = committee_members(fcr_store, later_start, later_end)?; + committee + .iter() + .filter(|&&index| !counted.contains(index)) + .fold(0, |sum: Gwei, &index| { + sum.saturating_add(balance_source.active(index)) + }) + } + }; fcr_store.run.slot_weights.borrow_mut().insert(key, weight); Ok(weight) } +/// Every member of the committees of `[start_slot, end_slot]`, as a bitmap, +/// memoized for the run. +fn committee_members( + fcr_store: &FastConfirmationStore, + start_slot: Slot, + end_slot: Slot, +) -> Result> { + let key = (start_slot, end_slot); + if let Some(members) = fcr_store.run.committee_members.borrow().get(&key) { + return Ok(Arc::clone(members)); + } + let mut members = ValidatorSet::default(); + for slot in start_slot..=end_slot { + for &index in get_slot_committee(fcr_store, slot)?.iter() { + members.insert(index); + } + } + let members = Arc::new(members); + fcr_store + .run + .committee_members + .borrow_mut() + .insert(key, Arc::clone(&members)); + Ok(members) +} + +/// A set of validator indices as a bitmap: a committee-sized membership test +/// costs one bit read per validator instead of a hash. +#[derive(Default)] +struct ValidatorSet { + words: Vec, + len: usize, +} + +impl ValidatorSet { + fn insert(&mut self, index: ValidatorIndex) { + let (word, bit) = ((index / 64) as usize, index % 64); + if self.words.len() <= word { + self.words.resize(word + 1, 0); + } + if self.words[word] & (1 << bit) == 0 { + self.words[word] |= 1 << bit; + self.len += 1; + } + } + + fn contains(&self, index: ValidatorIndex) -> bool { + let (word, bit) = ((index / 64) as usize, index % 64); + self.words + .get(word) + .is_some_and(|word| word & (1 << bit) != 0) + } + + fn is_empty(&self) -> bool { + self.len == 0 + } +} + +impl FromIterator for ValidatorSet { + fn from_iter>(indices: I) -> Self { + let mut set = Self::default(); + for index in indices { + set.insert(index); + } + set + } +} + /// `compute_committee_weight_between_slots`: total weight of the committees /// of `[start_slot, end_slot]`. pub fn compute_committee_weight_between_slots( @@ -723,30 +876,63 @@ pub fn compute_committee_weight_between_slots( } // Validators assigned to committees of the range that are active in the - // balance source; `active` is zero for the others. - let weight = if compute_epoch_at_slot(start_slot) == compute_epoch_at_slot(end_slot) { - let mut sum: Gwei = 0; - for slot in start_slot..=end_slot { - sum = sum.saturating_add(slot_committee_weight(fcr_store, balance_source, slot)?); - } - sum - } else { - participants_between_slots(fcr_store, start_slot, end_slot)? - .into_iter() - .fold(0, |sum: Gwei, index| { - sum.saturating_add(balance_source.active(index)) - }) - }; + // balance source; `active` is zero for the others. A range that covers no + // whole epoch spans one boundary at most: its later epoch's slots count + // whole, and its earlier epoch's slots count the members the later part + // has not already counted. + let boundary = compute_start_slot_at_epoch(compute_epoch_at_slot(end_slot)); + let later_start = boundary.max(start_slot); + let later = (start_slot < boundary).then_some((later_start, end_slot)); + debug_assert!( + start_slot >= boundary.saturating_sub(preset::SLOTS_PER_EPOCH), + "a range covering no whole epoch spans one boundary at most" + ); + let mut weight: Gwei = 0; + for slot in later_start..=end_slot { + let slot_weight = slot_committee_weight(fcr_store, balance_source, slot, None)?; + weight = weight.saturating_add(slot_weight); + } + for slot in start_slot..later_start { + let slot_weight = slot_committee_weight(fcr_store, balance_source, slot, later)?; + weight = weight.saturating_add(slot_weight); + } let weight = total_balance(weight); fcr_store.run.range_weights.borrow_mut().insert(key, weight); Ok(weight) } +/// The equivocating members of one slot's committees, memoized for the run. +fn slot_equivocators( + fcr_store: &FastConfirmationStore, + slot: Slot, +) -> Result>> { + if let Some(equivocators) = fcr_store.run.slot_equivocators.borrow().get(&slot) { + return Ok(Arc::clone(equivocators)); + } + let equivocating = &fcr_store.run.equivocating; + let equivocators: Vec = get_slot_committee(fcr_store, slot)? + .iter() + .copied() + .filter(|&index| equivocating.contains(index)) + .collect(); + let equivocators = Arc::new(equivocators); + fcr_store + .run + .slot_equivocators + .borrow_mut() + .insert(slot, Arc::clone(&equivocators)); + Ok(equivocators) +} + /// `get_equivocation_score`: total weight of the equivocating participants of /// the committees of `[start_slot, end_slot]`. /// /// Like the specification, it does not look in `balance_source` for slashed /// validators: those are very likely already equivocators. +/// +/// Built from each slot's few equivocating members ([`slot_equivocators`]), +/// shared by every range over that slot, rather than from the range's whole +/// participant list. pub fn get_equivocation_score( fcr_store: &FastConfirmationStore, balance_source: &BalanceSource, @@ -761,12 +947,17 @@ pub fn get_equivocation_score( if let Some(score) = fcr_store.run.equivocation_scores.borrow().get(&key) { return Ok(*score); } - let score = participants_between_slots(fcr_store, start_slot, end_slot)? - .into_iter() - .filter(|index| fcr_store.run.equivocating.contains(index)) - .fold(0, |sum: Gwei, index| { - sum.saturating_add(balance_source.active(index)) - }); + // A validator sits in one committee per epoch, so a range over several + // epochs can name one twice. + let mut equivocators: Vec = Vec::new(); + for slot in start_slot..=end_slot { + equivocators.extend(slot_equivocators(fcr_store, slot)?.iter().copied()); + } + equivocators.sort_unstable(); + equivocators.dedup(); + let score = equivocators.into_iter().fold(0, |sum: Gwei, index| { + sum.saturating_add(balance_source.active(index)) + }); fcr_store .run .equivocation_scores @@ -905,7 +1096,81 @@ pub fn compute_safety_threshold( } /// `get_attestation_score(store, get_node_for_root(root), balance_source)` -/// for every block at once. +/// for every block at once, memoized per balance source for the run. +/// +/// Read from the fork choice's cached vote weights when the source weighs +/// votes as the head does ([`cached_attestation_scores`]), and folded from the +/// latest messages otherwise ([`fold_attestation_scores`]); the two give the +/// same table, which a test holds them to. +fn attestation_scores( + fcr_store: &FastConfirmationStore, + balance_source: &BalanceSource, +) -> Arc> { + if let Some(scores) = fcr_store.run.scores.borrow().get(&balance_source.key) { + return Arc::clone(scores); + } + let scores = cached_attestation_scores(fcr_store, balance_source) + .unwrap_or_else(|| fold_attestation_scores(fcr_store, balance_source)); + let scores = Arc::new(scores); + fcr_store + .run + .scores + .borrow_mut() + .insert(balance_source.key, Arc::clone(&scores)); + scores +} + +/// [`attestation_scores`] from the store's cached fork-choice vote weights, +/// when `balance_source` holds the balances the head weighs votes by: the +/// justified checkpoint's, as one shared snapshot. +/// +/// The cache (`ethlambda-storage`'s proto-array) keeps, for every block, the +/// summed vote weight of the non-equivocating latest messages that land on it +/// or on a descendant, current as votes arrive. Its `PENDING` total covers +/// both payload branches, which is `get_attestation_score` of +/// [`get_node_for_root`] under either rule set: before gloas every vote and +/// every child sits under a block's one node. Reading it is a pass over the +/// blocks instead of one over every validator's latest message, the tally the +/// head computation stopped paying for the same reason, and which was most of +/// a run here. +/// +/// The read follows the head's own: the same balances snapshot, the same rule +/// set and the same window, so it never forces the cache to rebuild. `None`, +/// to fold instead, for any other balance source, and when the read fails +/// where the head's would (a vote on a block whose payload link is unknown). +fn cached_attestation_scores( + fcr_store: &FastConfirmationStore, + balance_source: &BalanceSource, +) -> Option> { + let store = fcr_store.store; + let justified = store.beacon_justified_checkpoint(); + if balance_source.key != BalanceKey::of(justified) { + return None; + } + // The head's snapshot, not merely equal balances: the cache follows one + // snapshot by identity, and a different one would rebuild it. + let balances = store.justified_balances(&justified)?; + if !Arc::ptr_eq(&balances, &balance_source.balances) { + return None; + } + let rules = match fcr_store.run.rules { + ForkRules::PreGloas => WeightRules::PreGloas, + ForkRules::Gloas => WeightRules::Gloas, + }; + let index = &fcr_store.run.index; + let bound = fork_choice::walk_bound(store, index); + let weights = store + .proto_array_weights(index, &balances, bound, rules) + .ok()?; + let scores = weights + .into_iter() + .filter(|(node, _)| node.payload_status == PayloadStatus::Pending) + .map(|(node, weight)| (node.root, weight)) + .collect(); + Some(scores) +} + +/// [`attestation_scores`] by a pass over the latest messages. /// /// A vote counts for a block exactly when the block it supports descends from /// it, so each vote is summed at its own block and every block's total is @@ -914,14 +1179,11 @@ pub fn compute_safety_threshold( /// payload branch, so the walk is by root there too. A vote for a block /// outside the index (pruned below finality) descends from no block scored /// here, and the fold never reaches it. -fn attestation_scores( +fn fold_attestation_scores( fcr_store: &FastConfirmationStore, balance_source: &BalanceSource, -) -> Arc> { - if let Some(scores) = fcr_store.run.scores.borrow().get(&balance_source.key) { - return Arc::clone(scores); - } - let mut scores: HashMap = HashMap::new(); +) -> RootMap { + let mut scores = RootMap::::default(); // Nothing may read the store's own scratch inside this closure. fcr_store .store @@ -947,13 +1209,6 @@ fn attestation_scores( *entry = entry.saturating_add(score); } } - - let scores = Arc::new(scores); - fcr_store - .run - .scores - .borrow_mut() - .insert(balance_source.key, Arc::clone(&scores)); scores } @@ -1047,42 +1302,77 @@ fn get_latest_message_epoch(fcr_store: &FastConfirmationStore, message: &LatestM /// `get_current_target_score`: the estimate of FFG support of the current /// epoch's target from LMD-GHOST votes. Must be used no later than the end of /// the current epoch. +/// +/// Only a vote from the target's own epoch is walked. The checkpoint +/// `get_checkpoint_for_block(root, epoch)` returns carries the vote's epoch, +/// so a vote from any other epoch fails the equality with `target` whatever +/// its root, and skipping it first changes no result. The skip is what keeps +/// the walk off the votes the live index no longer holds: a validator quiet +/// since before the finalized checkpoint still has a latest message, whose +/// root [`block`] can only answer from the block table, which on the beacon +/// chain decodes a whole block per parent hop. Walking those, one hop at a +/// time back to their epoch's first slot, is what made the runs that reach +/// this function (the first slots of an epoch) take over a second on mainnet. +/// +/// Memoized for the run: both `will_*_be_justified` checks reach it, through +/// [`compute_honest_ffg_support_for_current_target`]. pub fn get_current_target_score(fcr_store: &FastConfirmationStore) -> Result { + if let Some(score) = fcr_store.run.current_target_score.get() { + return Ok(*score); + } let target = get_current_target(fcr_store)?; let balance_source = balance_source(fcr_store, BalanceKey::PulledUpHead)?; - // The weight behind each distinct `(root, epoch)` vote, so the checkpoint - // walk below runs once per distinct vote and not once per validator. - let mut by_vote: HashMap<(Root, Epoch), Gwei> = HashMap::new(); + // The weight behind each distinct root voted for in the target's epoch, + // so the checkpoint walk below runs once per distinct vote and not once + // per validator. + let mut by_root = RootMap::::default(); fcr_store .store .for_each_non_equivocating_latest_message(|index, message| { + if get_latest_message_epoch(fcr_store, &message) != target.epoch { + return; + } let balance = balance_source.unslashed(index); if balance != 0 { - let epoch = get_latest_message_epoch(fcr_store, &message); - let entry = by_vote.entry((message.root, epoch)).or_default(); + let entry = by_root.entry(message.root).or_default(); *entry = entry.saturating_add(balance); } }); let mut score: Gwei = 0; - for ((root, epoch), balance) in by_vote { + for (root, balance) in by_root { // A vote for a block this store no longer holds supports no target. - if let Ok(checkpoint) = get_checkpoint_for_block(fcr_store, root, epoch) + if let Ok(checkpoint) = get_checkpoint_for_block(fcr_store, root, target.epoch) && checkpoint == target { score = score.saturating_add(balance); } } + let _ = fcr_store.run.current_target_score.set(score); Ok(score) } /// `compute_honest_ffg_support_for_current_target`: the honest FFG support of /// the current epoch's target, assuming [`CONFIRMATION_BYZANTINE_THRESHOLD`] /// and synchrony, counting the votes received so far. +/// +/// Memoized for the run, like [`get_current_target_score`]: nothing it reads +/// moves within one. pub fn compute_honest_ffg_support_for_current_target( fcr_store: &FastConfirmationStore, ) -> Result { + if let Some(support) = fcr_store.run.honest_ffg_support.get() { + return Ok(*support); + } + let support = compute_honest_ffg_support_uncached(fcr_store)?; + let _ = fcr_store.run.honest_ffg_support.set(support); + Ok(support) +} + +/// [`compute_honest_ffg_support_for_current_target`]'s computation, run once +/// per run. +fn compute_honest_ffg_support_uncached(fcr_store: &FastConfirmationStore) -> Result { let current_slot = fcr_store.run.current_slot; let current_epoch = compute_epoch_at_slot(current_slot); let balance_source = balance_source(fcr_store, BalanceKey::PulledUpHead)?; @@ -1493,6 +1783,8 @@ pub fn run_fast_confirmation( mod tests { use ethlambda_storage::backend::InMemoryBackend; + use std::collections::HashSet; + use super::*; use crate::beacon::containers::{BeaconBlockHeader, SignedBeaconBlock, gloas, phase0}; use crate::beacon::fork::ForkName; @@ -2284,6 +2576,238 @@ mod tests { } } + /// The fork choice's cached vote weights give the justified balance source + /// the same scores as the fold over the latest messages, on a tree with a + /// fork whose two blocks split a committee and an equivocator among the + /// voters. + #[test] + fn the_cached_scores_match_the_fold() { + let mut chain = Chain::new(); + chain.run_slots(SPE + 4, 80); + let fork_point = chain.head(); + let slot = chain.current_slot(); + let a = chain.add_block_with(fork_point, slot, Root::repeat_byte(1)); + let b = chain.add_block_with(fork_point, slot, Root::repeat_byte(2)); + let committee = chain.committee(slot); + let (voters_a, voters_b) = committee.split_at(committee.len() / 3); + chain.vote(voters_a.to_vec(), a, slot); + chain.vote(voters_b.to_vec(), b, slot); + chain.next_slot(); + chain.store.insert_equivocating_index(voters_a[0]); + // The head computation reads the cache first, as the tick does. + chain.head(); + + let fcr = chain.fcr(); + let justified = chain.store.beacon_justified_checkpoint(); + let source = balance_source(&fcr, BalanceKey::of(justified)).expect("source"); + let cached = cached_attestation_scores(&fcr, &source) + .expect("the justified balance source reads the cache"); + let folded = fold_attestation_scores(&fcr, &source); + for root in fcr.run.index.keys() { + assert_eq!( + cached.get(root).copied().unwrap_or(0), + folded.get(root).copied().unwrap_or(0), + "score of {root}" + ); + } + assert!(cached.get(&a).is_some_and(|&score| score > 0)); + assert!(cached.get(&b).is_some_and(|&score| score > 0)); + } + + /// Every figure a balance source answers, for every validator. + fn assert_same_balances(source: &BalanceSource, expected: &JustifiedBalances) { + for index in 0..=validators() as ValidatorIndex { + assert_eq!(source.unslashed(index), expected.get(index), "{index}"); + assert_eq!( + source.active(index), + expected.active_balance(index), + "{index}" + ); + } + assert_eq!(source.total_active(), expected.total_active_balance()); + assert_eq!(source.total_unslashed(), expected.total_balance()); + } + + /// The pulled-up head's balances, read from the current target's cache + /// entry, are the ones the pulled-up head state itself gives; a slashing + /// in the current epoch's blocks is the one change that would make the + /// target's wrong, and it sends the read to the state. + #[test] + fn the_pulled_up_balances_are_the_pulled_up_states() { + let mut chain = Chain::new(); + // The head mid-epoch, past the epoch's first block (the target). + chain.run_slots(SPE + 3, 90); + let fcr = chain.fcr(); + assert_ne!(get_current_target(&fcr).unwrap().root, chain.head()); + let source = balance_source(&fcr, BalanceKey::PulledUpHead).unwrap(); + let state = get_pulled_up_head_state(&fcr).unwrap(); + let expected = fork_choice::build_justified_balances(Checkpoint::default(), &state); + assert_same_balances(&source, &expected); + + // A block that slashes validator 3, built the way the state transition + // would leave it: the flag set, and the epoch's slashings entry raised. + let slashed: ValidatorIndex = 3; + let epoch = chain.current_epoch(); + let effective_balance = chain.base.validator(slashed).unwrap().effective_balance; + chain.base.validator_mut(slashed).unwrap().slashed = true; + chain.base.slashings_mut()[epoch as usize % preset::EPOCHS_PER_SLASHINGS_VECTOR] = + effective_balance; + chain.next_slot_with_block_on(None, 90); + + let fcr = chain.fcr(); + let target = get_current_target(&fcr).unwrap(); + assert_eq!(target.epoch, epoch, "still the same epoch"); + let target_balances = + fork_choice::checkpoint_balances(&chain.store, &target, &chain.config).unwrap(); + assert_ne!( + target_balances.get(slashed), + 0, + "the target's state predates the slashing" + ); + let source = balance_source(&fcr, BalanceKey::PulledUpHead).unwrap(); + assert_eq!(source.unslashed(slashed), 0); + let state = get_pulled_up_head_state(&fcr).unwrap(); + let expected = fork_choice::build_justified_balances(Checkpoint::default(), &state); + assert_same_balances(&source, &expected); + } + + /// Mainnet-sized timing of the rule beside the head computation it follows + /// on the tick: a block every slot, 90% of each slot's committee voting for + /// the head, and one run per slot after two epochs of warm-up, reported by + /// slot of the epoch (the first slots of an epoch take the paths the rest + /// do not). Ignored: a measurement, not a check. `FCR_BENCH_VALIDATORS` + /// sets the registry size (default one million). Run with `cargo test -p + /// ethlambda-state-transition --profile release-fast --lib -- --ignored + /// --nocapture fast_confirmation_timing`. + /// + /// The harness inserts blocks without `on_block`, so the loop caches a + /// first-slot block's balances itself, outside the timing, as an import + /// does (`cache_first_slot_balances`). + #[test] + #[ignore = "benchmark"] + fn fast_confirmation_timing() { + use std::time::{Duration, Instant}; + + let validators = std::env::var("FCR_BENCH_VALIDATORS") + .ok() + .and_then(|count| count.parse().ok()) + .unwrap_or(1_000_000); + let epochs = 3; + let setup = Instant::now(); + let mut state = test_state::with_validators(validators); + for index in 0..preset::EPOCHS_PER_HISTORICAL_VECTOR { + state.randao_mixes_mut()[index] = Root::repeat_byte(index as u8 + 1); + } + let mut chain = Chain::from_state(state); + chain.run_slots(2 * SPE, 90); + eprintln!("setup {:?}", setup.elapsed()); + + let mean = |runs: &[Duration]| runs.iter().sum::() / runs.len().max(1) as u32; + let max = |runs: &[Duration]| runs.iter().max().copied().unwrap_or_default(); + // (head, rule) per run, by slot of the epoch. + let mut by_slot: Vec<(Vec, Vec)> = + vec![Default::default(); SPE as usize]; + for _ in 0..epochs * SPE { + let block = chain.next_slot_with_block_on(None, 90); + let block_slot = chain.slot_of(block); + if is_start_slot_at_epoch(block_slot) { + let checkpoint = Checkpoint { + epoch: compute_epoch_at_slot(block_slot), + root: block, + }; + fork_choice::checkpoint_balances(&chain.store, &checkpoint, &chain.config) + .expect("the block's state is stored"); + } + let slot = chain.current_slot(); + let start = Instant::now(); + let head = chain.head(); + let head_time = start.elapsed(); + let start = Instant::now(); + run_fast_confirmation(&chain.store, &chain.config, head).expect("the rule runs"); + let rule_time = start.elapsed(); + let (heads, rules) = &mut by_slot[(slot % SPE) as usize]; + heads.push(head_time); + rules.push(rule_time); + } + + let confirmed = chain.slot_of(chain.confirmed_root()); + eprintln!( + "validators {validators}, {epochs} epochs, head slot {}, confirmed slot {confirmed}", + chain.slot_of(chain.head()) + ); + eprintln!("slot of epoch: get_head mean | rule mean (max)"); + for (slot, (heads, rules)) in by_slot.iter().enumerate() { + eprintln!( + " {slot:2}: {:?} | {:?} ({:?})", + mean(heads), + mean(rules), + max(rules) + ); + } + let heads: Vec = by_slot + .iter() + .flat_map(|(heads, _)| heads.clone()) + .collect(); + let rules: Vec = by_slot + .iter() + .flat_map(|(_, rules)| rules.clone()) + .collect(); + eprintln!( + "overall: get_head mean {:?} | rule mean {:?}", + mean(&heads), + mean(&rules) + ); + } + + /// `get_current_target_score` walks only the target epoch's votes; the + /// specification walks every vote. The two agree because the checkpoint + /// a vote names carries the vote's own epoch. The scenario holds all three + /// kinds of vote: earlier-epoch votes, current-epoch votes for the target, + /// and current-epoch votes for a sibling of the epoch's first block, which + /// name a different target root. + #[test] + fn the_target_score_matches_the_specifications_unfiltered_sum() { + let mut chain = Chain::new(); + chain.run_slots(SPE + 3, 80); + let epoch_start = compute_start_slot_at_epoch(chain.current_epoch()); + let before_start = get_ancestor_root(&chain.fcr(), chain.head(), epoch_start - 1) + .expect("the canonical chain is indexed"); + let slot = chain.current_slot(); + let sibling = chain.add_block_with(before_start, slot, Root::repeat_byte(3)); + let committee = chain.committee(slot); + chain.vote(committee[..committee.len() / 2].to_vec(), sibling, slot); + chain.next_slot(); + + let fcr = chain.fcr(); + let target = get_current_target(&fcr).expect("target"); + let source = balance_source(&fcr, BalanceKey::PulledUpHead).expect("source"); + let mut messages = Vec::new(); + chain + .store + .for_each_non_equivocating_latest_message(|index, message| { + messages.push((index, message)); + }); + let (mut expected, mut matching, mut other_target, mut other_epoch) = (0, 0, 0, 0); + for (index, message) in messages { + let epoch = get_latest_message_epoch(&fcr, &message); + let names_target = get_checkpoint_for_block(&fcr, message.root, epoch) + .is_ok_and(|checkpoint| checkpoint == target); + if names_target { + expected += source.unslashed(index); + matching += 1; + } else if epoch == target.epoch { + other_target += 1; + } else { + other_epoch += 1; + } + } + assert!( + matching > 0 && other_target > 0 && other_epoch > 0, + "the scenario holds every kind of vote: {matching} {other_target} {other_epoch}" + ); + assert_eq!(get_current_target_score(&fcr).expect("score"), expected); + } + #[test] fn the_score_fold_matches_the_specifications_per_block_attestation_score() { let mut chain = Chain::new(); @@ -2588,6 +3112,113 @@ mod tests { }) } + /// Under the gloas rules the cached weights split every block into its + /// payload branches, and a child hands its weight to the branch it builds + /// on. The `PENDING` total the rule reads is still the fold's per-root + /// score: a vote for either branch of the parent counts for it, and so does + /// the child that builds on its FULL payload. + #[test] + fn the_cached_scores_match_the_fold_under_gloas() { + let config = Config::active().with_fork_epoch(ForkName::Gloas, 1); + let gloas_start = compute_start_slot_at_epoch(1); + let anchor_root = Root::repeat_byte(0xa0); + let anchor = Checkpoint { + epoch: 0, + root: anchor_root, + }; + let mut store = Store::init_beacon( + Arc::new(InMemoryBackend::new()), + 0, + config.clone(), + anchor_root, + Store::beacon_checkpoint_as_stored(anchor), + 0, + ); + let hash = |byte| ExecutionBlockHash::repeat_byte(byte); + let state = test_state::with_validators_at(ForkName::Gloas, validators()); + + // anchor -> parent (slot s) -> [empty slot] -> child (slot s + 2), the + // child building on the parent's FULL payload. + let parent_root = Root::repeat_byte(0xb0); + let child_root = Root::repeat_byte(0xc0); + let (parent_slot, child_slot) = (gloas_start, gloas_start + 2); + for (root, signed) in [ + ( + anchor_root, + gloas_block(0, Root::ZERO, ExecutionBlockHash::ZERO, hash(1)), + ), + ( + parent_root, + gloas_block(parent_slot, anchor_root, hash(1), hash(2)), + ), + ( + child_root, + gloas_block(child_slot, parent_root, hash(2), hash(3)), + ), + ] { + store.insert_signed_block(root, signed).expect("insert"); + let mut post = state.clone(); + *post.slot_mut() = store.block_entry(&root).unwrap().0.max(gloas_start); + store.insert_state(root, post).expect("insert state"); + // What the head computation records before the rule runs. + fork_choice::ensure_payload_link(&store, root).expect("link"); + } + store + .set_time_ms((child_slot + 2) * config.slot_duration_ms) + .unwrap(); + + // The empty slot's committee splits between the parent's two payload + // branches; the child's slot committee votes for the child. + let fcr = FastConfirmationStore::new( + &store, + &config, + FastConfirmationVariables::at_finalized(anchor), + child_root, + ); + let empty_slot_committee = get_slot_committee(&fcr, parent_slot + 1).unwrap(); + let child_slot_committee = get_slot_committee(&fcr, child_slot).unwrap(); + drop(fcr); + let (full_voters, empty_voters) = + empty_slot_committee.split_at(empty_slot_committee.len() / 2); + let votes = [ + (full_voters, parent_slot + 1, parent_root, true), + (empty_voters, parent_slot + 1, parent_root, false), + (&child_slot_committee[..], child_slot, child_root, false), + ]; + for (voters, slot, root, payload_present) in votes { + for &index in voters { + let message = LatestMessage { + epoch: 1, + slot, + root, + payload_present, + }; + store.set_latest_message(index, message); + } + } + + let fcr = FastConfirmationStore::new( + &store, + &config, + FastConfirmationVariables::at_finalized(anchor), + child_root, + ); + assert_eq!(fcr.run.rules, ForkRules::Gloas); + let justified = store.beacon_justified_checkpoint(); + let source = balance_source(&fcr, BalanceKey::of(justified)).unwrap(); + let cached = cached_attestation_scores(&fcr, &source) + .expect("the justified balance source reads the cache"); + let folded = fold_attestation_scores(&fcr, &source); + for root in [anchor_root, parent_root, child_root] { + assert_eq!( + cached.get(&root).copied().unwrap_or(0), + folded.get(&root).copied().unwrap_or(0), + "score of {root}" + ); + } + assert!(folded.get(&parent_root).copied().unwrap_or(0) > folded[&child_root]); + } + /// The gloas `test_is_one_confirmed_delayed_with_mixed_payload_votes_in_empty_slot` /// scenario, down to the term it exercises: after an empty slot, only the /// votes for the parent's payload branch that B builds on (FULL) count as diff --git a/crates/blockchain/state_transition/src/beacon/fork_choice.rs b/crates/blockchain/state_transition/src/beacon/fork_choice.rs index 6f064265e..b874dee0f 100644 --- a/crates/blockchain/state_transition/src/beacon/fork_choice.rs +++ b/crates/blockchain/state_transition/src/beacon/fork_choice.rs @@ -1541,21 +1541,51 @@ fn cache_first_slot_balances(store: &Store, post_state: &BeaconState, block_root cache_boundary_balances(store, checkpoint, post_state); } +/// The flat balances of `checkpoint`'s state, from the same per-checkpoint +/// cache [`justified_balances`] reads, built from [`checkpoint_state`] and +/// cached on a miss. +/// +/// For the Fast Confirmation Rule, whose balance sources are checkpoint +/// states too (the observed justified checkpoints and the current target): +/// reading them here means a registry pass once per checkpoint, usually +/// already paid at the boundary import, rather than once per run. Not counted +/// in the justified-balances lookup metric, which is about the head's reads. +pub fn checkpoint_balances( + store: &Store, + checkpoint: &Checkpoint, + config: &Config, +) -> Result> { + if let Some(balances) = store.justified_balances(checkpoint) { + return Ok(balances); + } + let state = checkpoint_state(store, checkpoint, config)?; + let balances = Arc::new(build_justified_balances(*checkpoint, &state)); + store.insert_justified_balances(Arc::clone(&balances)); + Ok(balances) +} + /// One in-order pass over `state`'s registry: each validator's vote weight /// (zero if inactive or slashed), its raw effective balance in increments, and, /// in the same pass, the sum of the vote weights, which is the total /// [`calculate_committee_fraction`] divides (saturating, floored at one -/// increment). -fn build_justified_balances(checkpoint: Checkpoint, state: &BeaconState) -> JustifiedBalances { +/// increment), plus the active set's own figures: the active validators the +/// vote weight zeroes for being slashed, and the total active balance. +pub(crate) fn build_justified_balances( + checkpoint: Checkpoint, + state: &BeaconState, +) -> JustifiedBalances { let _timing = metrics::time_justified_balances_build(); // Activity at the state's own epoch, as `get_weight` reads it; not asserted // equal to `checkpoint.epoch`, which the unit-test stores do not keep. let epoch = get_current_epoch(state); let mut total: Gwei = 0; + let mut total_active: Gwei = 0; + let mut active_slashed: Vec = Vec::new(); let mut increments: Vec = Vec::new(); let balances = state .iter_validators() - .map(|validator| { + .enumerate() + .map(|(index, validator)| { // An effective balance is a whole number of increments no larger // than `MAX_EFFECTIVE_BALANCE_ELECTRA`; the snapshot's exactness // depends on it. @@ -1568,6 +1598,12 @@ fn build_justified_balances(checkpoint: Checkpoint, state: &BeaconState) -> Just u16::try_from(validator.effective_balance / preset::EFFECTIVE_BALANCE_INCREMENT) .expect("effective balance fits u16 increments"), ); + if is_active_validator(validator, epoch) { + total_active = total_active.saturating_add(validator.effective_balance); + if validator.slashed { + active_slashed.push(index as ValidatorIndex); + } + } let balance = vote_balance(validator, epoch); total = total.saturating_add(balance); balance @@ -1578,6 +1614,8 @@ fn build_justified_balances(checkpoint: Checkpoint, state: &BeaconState) -> Just balances, total.max(preset::EFFECTIVE_BALANCE_INCREMENT), increments.into(), + active_slashed.into(), + total_active, ) } @@ -2249,7 +2287,11 @@ fn boost_reaches_the_descent( /// blocks, which weighs everything. Why each term is there, and why the least /// is the finalized block's slot on a live node, is argued once, in /// [`compute_node_weights`]'s `# The bound` section. -fn walk_bound(store: &Store, index: &HashMap) -> Slot { +/// +/// The Fast Confirmation Rule reads the cached weights over the same window, +/// which holds every block it scores (all at or above the finalized one), and +/// which the head's own read has just shown readable. +pub(crate) fn walk_bound(store: &Store, index: &HashMap) -> Slot { let slot_of = |root: Root| index.get(&root).map_or(0, |&(slot, _)| slot); let mut bound = slot_of(store.beacon_finalized_checkpoint().root) .min(slot_of(store.beacon_justified_checkpoint().root)); @@ -10289,6 +10331,8 @@ mod tests { vec![32 * increment; VALIDATORS as usize].into(), VALIDATORS * 32 * increment, vec![32; VALIDATORS as usize].into(), + Box::default(), + VALIDATORS * 32 * increment, ))); let config = Config::active(); store diff --git a/crates/common/types/src/beacon/fork_choice.rs b/crates/common/types/src/beacon/fork_choice.rs index 9901d53f3..6af452e76 100644 --- a/crates/common/types/src/beacon/fork_choice.rs +++ b/crates/common/types/src/beacon/fork_choice.rs @@ -261,6 +261,12 @@ impl FastConfirmationVariables { /// /// Held in the store, hence here rather than in `ethlambda-state-transition`; /// the builder lives there, next to the state accessors. +/// +/// Despite the name, any epoch-boundary checkpoint's balances take this shape: +/// the Fast Confirmation Rule reads its balance sources (the observed justified +/// checkpoints and the current target) through the same per-checkpoint cache, +/// which is why it also carries the active-set figures that rule's committee +/// weights need ([`Self::active_balance`], [`Self::total_active_balance`]). #[derive(Debug, PartialEq, Eq)] pub struct JustifiedBalances { checkpoint: Checkpoint, @@ -283,24 +289,64 @@ pub struct JustifiedBalances { /// effective balance of every equivocating validator in the head slot's /// committees, and an equivocator is often slashed. effective_increments: Box<[u16]>, + /// The validators active at `checkpoint.epoch` that are slashed, in + /// ascending order: the ones `balances` zeroes although the active set + /// still counts them. Few, since slashings are rare and a slashed validator + /// is on its way out. + active_slashed: Box<[ValidatorIndex]>, + /// The effective balance of every validator active at `checkpoint.epoch`, + /// slashed or not: `get_total_active_balance` before its floor. + total_active_balance: Gwei, } impl JustifiedBalances { /// Wraps already-computed values; see the field docs for what they mean. + /// `active_slashed` must be in ascending order. pub fn new( checkpoint: Checkpoint, balances: Box<[Gwei]>, total_balance: Gwei, effective_increments: Box<[u16]>, + active_slashed: Box<[ValidatorIndex]>, + total_active_balance: Gwei, ) -> Self { + debug_assert!( + active_slashed.is_sorted(), + "active_slashed is in ascending order" + ); Self { checkpoint, balances, total_balance, effective_increments, + active_slashed, + total_active_balance, } } + /// `index`'s effective balance if it is active at the checkpoint's epoch, + /// slashed or not; zero otherwise, and past the end. A slashed validator + /// still sits in its committees, so this is the weight a committee member + /// carries in the Fast Confirmation Rule's committee sums. + /// + /// The vote weight answers it except for an active slashed validator, the + /// one case that pays a search, and only through the short + /// `active_slashed` list. + pub fn active_balance(&self, index: ValidatorIndex) -> Gwei { + let balance = self.get(index); + if balance != 0 || self.active_slashed.binary_search(&index).is_err() { + return balance; + } + self.effective_balance(index) + } + + /// The effective balance of the checkpoint state's active validators, + /// slashed or not, unfloored: the specification's + /// `get_total_active_balance` before its one-increment floor. + pub fn total_active_balance(&self) -> Gwei { + self.total_active_balance + } + /// `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 { @@ -348,6 +394,8 @@ mod tests { vec![7, 0, 9].into(), 16, vec![1, 2, 3].into(), + Box::default(), + 16, ); assert_eq!(balances.get(0), 7); assert_eq!(balances.get(1), 0); @@ -361,6 +409,32 @@ mod tests { assert_eq!(balances.effective_balance(u64::MAX), 0); } + /// An active slashed validator weighs nothing as a voter but its full + /// effective balance as a member of the active set; an inactive one weighs + /// nothing either way. + #[test] + fn the_active_balance_counts_an_active_slashed_validator() { + let increment = crate::beacon::preset::EFFECTIVE_BALANCE_INCREMENT; + // 0 active, 1 active and slashed, 2 inactive, 3 active. + let balances = JustifiedBalances::new( + Checkpoint::default(), + vec![32 * increment, 0, 0, 16 * increment].into(), + 48 * increment, + vec![32, 32, 32, 16].into(), + vec![1].into(), + 80 * increment, + ); + assert_eq!(balances.get(1), 0); + assert_eq!(balances.active_balance(0), 32 * increment); + assert_eq!(balances.active_balance(1), 32 * increment); + assert_eq!(balances.active_balance(2), 0); + assert_eq!(balances.active_balance(3), 16 * increment); + assert_eq!(balances.active_balance(4), 0); + assert_eq!(balances.active_balance(u64::MAX), 0); + assert_eq!(balances.total_active_balance(), 80 * increment); + assert_eq!(balances.total_balance(), 48 * increment); + } + #[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/proto_array.rs b/crates/storage/src/proto_array.rs index 7386dc528..a8a6e8058 100644 --- a/crates/storage/src/proto_array.rs +++ b/crates/storage/src/proto_array.rs @@ -647,6 +647,8 @@ mod tests { values.to_vec().into(), total, increments, + Box::default(), + total, )) } diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index c6e20f02f..088b55e9e 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -10684,6 +10684,8 @@ mod tests { vec![1].into(), 1, vec![1].into(), + Box::default(), + 1, )) } diff --git a/docs/beacon_stf.md b/docs/beacon_stf.md index 96640edf0..3826792aa 100644 --- a/docs/beacon_stf.md +++ b/docs/beacon_stf.md @@ -573,12 +573,37 @@ assertion). A later bump should diff those three files against that commit. rule assumes synchrony from the start of the previous slot. - **Outputs.** The `fast_confirmation` Beacon API event, after every run, and the engine's safe block hash (`get_safe_execution_block_hash`). -- **Cost.** One run is `O(V + M + B log B)`: a pass over each of at most three - balance sources' registries (`V` validators), one bottom-up fold of the latest - messages (`M`) into every block's attestation score, and a sort of the live - chain (`B` blocks), plus committee-range work that is a sum over cached slot - weights except for ranges that cross an epoch boundary without covering an - epoch. `lean_beacon_fast_confirmation_time_seconds` times a run. +- **Cost.** A run reuses what the fork choice already keeps, so most runs cost + about what the head computation does: + - Balance sources are the store's per-checkpoint balances + (`fork_choice::checkpoint_balances`, the cache the head's justified + balances come from), which also carry the active set's figures + (`JustifiedBalances::active_balance`, `total_active_balance`). A registry + pass is paid once per checkpoint, at the boundary import, not per run. The + pulled-up head state's balances are the current target's, exact while no + validator was slashed in the current epoch (the head state's + `slashings[current_epoch % EPOCHS_PER_SLASHINGS_VECTOR]` is still zero); + otherwise they are built from the state for that run. + - Attestation scores are the proto-array's `PENDING` subtree totals + (`Store::proto_array_weights`) when the source is the justified + checkpoint's, read with the head's own snapshot, rule set and window so the + array never rebuilds for it. Only the previous epoch's source, at an epoch's + first slot, folds the latest messages (foldhash-keyed). + - Committee weights are per-slot sums memoized for the run; a range across an + epoch boundary sums its earlier epoch's slots less the members its later + part counts, and equivocators are a bitmap collected once per slot. + - `get_current_target_score`, reached only in an epoch's first slots, walks + only the roots voted for in the current epoch, once per run. Walking every + vote's root decoded a whole block per parent hop for votes below the live + index, and took over a second on mainnet; a vote from another epoch never + names the target, so skipping it changes no result. + + At a million validators (`fast_confirmation_timing`, an ignored benchmark in + the module) a run takes about 0.5 ms mid-epoch against the head + computation's 0.08 ms, 2 to 3 ms in an epoch's second and third slots, and + about 14 ms in its first, as the head computation's once-an-epoch + proto-array rebuild does. `lean_beacon_fast_confirmation_time_seconds` times + a run. - **Restart.** The confirmed root is persisted when it changes, and the first synced run of a process offers it to `get_restart_resilient_confirmed_root`. - **Tests.** No fixtures exist; the pyspec scenarios are ported as unit tests in