From decf6e5a5019ca202a4af2d2954486430b90091d Mon Sep 17 00:00:00 2001 From: bomanaps Date: Fri, 3 Apr 2026 16:24:52 +0100 Subject: [PATCH 1/7] Add Fast Confirmation Rule (FCR) implementation --- factory/src/lib.rs | 33 + features/src/lib.rs | 1 + fork_choice_control/src/extra_tests.rs | 268 ++++++++ fork_choice_control/src/helpers.rs | 40 ++ fork_choice_control/src/queries.rs | 30 + fork_choice_store/src/fast_confirmation.rs | 427 +++++++++++++ fork_choice_store/src/lib.rs | 1 + fork_choice_store/src/store.rs | 703 ++++++++++++++++++++- http_api/src/block_id.rs | 2 + http_api_utils/src/block_id.rs | 1 + 10 files changed, 1504 insertions(+), 2 deletions(-) create mode 100644 fork_choice_store/src/fast_confirmation.rs diff --git a/factory/src/lib.rs b/factory/src/lib.rs index 8cd286eca..c869d53ee 100644 --- a/factory/src/lib.rs +++ b/factory/src/lib.rs @@ -167,6 +167,39 @@ pub fn block_justifying_current_epoch( ) } +/// Creates a block at `block_slot` that includes attestations for the given `attestation_slots`. +/// +/// Unlike [`block_justifying_current_epoch`], the caller controls both the block slot and the +/// range of slots for which attestations are included. This is useful for tests that need to +/// justify an epoch with a block that is not at the last slot of the epoch (for example, when +/// the GU snapshot in FCR must be taken at a specific slot). +pub fn block_with_attestations_for_slots( + config: &Config, + pubkey_cache: &PubkeyCache, + pre_state: Arc>, + block_slot: Slot, + attestation_slots: core::ops::Range, + graffiti: H256, +) -> Result> { + let advanced_state = advance_state(config, pubkey_cache, pre_state, block_slot)?; + let eth1_data = advanced_state.eth1_data(); + let attestations = full_block_attestations(config, &advanced_state, attestation_slots)?; + let deposits = ContiguousList::default(); + let sync_aggregate = SyncAggregate::empty(); + + block( + config, + pubkey_cache, + advanced_state, + eth1_data, + graffiti, + attestations, + deposits, + sync_aggregate, + None, + ) +} + pub fn block_with_deposits( config: &Config, pubkey_cache: &PubkeyCache, diff --git a/features/src/lib.rs b/features/src/lib.rs index 5691f4981..b55dc8538 100644 --- a/features/src/lib.rs +++ b/features/src/lib.rs @@ -43,6 +43,7 @@ pub enum Feature { DumpBeaconBlocks, DumpBeaconStates, DumpBlobSidecars, + FastConfirmationRule, IgnoreAttestationsForUnknownBlocks, IgnoreFutureAttestations, InhibitApplicationRestart, diff --git a/fork_choice_control/src/extra_tests.rs b/fork_choice_control/src/extra_tests.rs index c997e87f3..dea58a2d3 100644 --- a/fork_choice_control/src/extra_tests.rs +++ b/fork_choice_control/src/extra_tests.rs @@ -18,6 +18,7 @@ use std::sync::Arc; use eth2_cache_utils::medalla; #[cfg(feature = "eth2-cache")] use eth2_libp2p::GossipId; +use features::Feature; use helper_functions::misc; #[cfg(feature = "eth2-cache")] use std_ext::ArcExt as _; @@ -2522,3 +2523,270 @@ fn reorganizing_due_to_invalidation_sends_notifications_if_common_ancestor_is_un unfinalized_block_count_total: 1, }); } + +// ───────────────────────────────────────────────────────────────────────────── +// Fast Confirmation Rule (FCR) tests +// ───────────────────────────────────────────────────────────────────────────── + +/// RAII guard that serializes FCR tests and disables `FastConfirmationRule` on drop. +/// +/// `Feature::FastConfirmationRule` is a global flag. Without serialization, two FCR +/// tests running concurrently can race: one test's `drop` disables the flag while the +/// other test is still running, causing `confirmed_root()` to fall back to +/// `justified_checkpoint.root` and break assertions. +/// +/// Holding `FCR_LOCK` ensures at most one FCR test runs at a time. +static FCR_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +struct FcrGuard(std::sync::MutexGuard<'static, ()>); + +impl FcrGuard { + fn new() -> Self { + Self(FCR_LOCK.lock().expect("FCR test mutex is poisoned")) + } +} + +impl Drop for FcrGuard { + fn drop(&mut self) { + Feature::FastConfirmationRule.set_enabled(false); + } +} + +/// When FCR is disabled, `confirmed_root()` is an alias for `justified_checkpoint().root`. +/// This verifies the fallback path stays correct after justification advances. +#[test] +fn fcr_disabled_confirmed_root_tracks_justified_checkpoint() { + Feature::FastConfirmationRule.set_enabled(false); + + let mut context = Context::minimal(); + + let (_, state_0) = context.genesis(); + let (block_1, state_1) = context.empty_block(&state_0, start_of_epoch(1), H256::default()); + let (block_2, _) = + context.block_justifying_current_epoch(&state_1, 1, H256::repeat_byte(1)); + + // Advance to epoch 3 so block_2's justification is fully committed. + context.on_slot(start_of_epoch(3)); + context.on_acceptable_block(&block_1); + context.on_acceptable_block(&block_2); + + let justified = context.justified_checkpoint(); + let confirmed = context.confirmed_root(); + + assert_eq!(confirmed, justified.root); + assert_ne!(confirmed, H256::zero()); +} + +/// When FCR is enabled and no attestations have been cast, the confirmed root must +/// stay at the finalized checkpoint root — FCR must not advance without votes. +#[test] +fn fcr_enabled_stays_at_finalized_without_attestation_support() { + let _guard = FcrGuard::new(); + Feature::FastConfirmationRule.enable(); + + let mut context = Context::minimal(); + + let (_, state_0) = context.genesis(); + let (block_1, _) = context.empty_block(&state_0, 1, H256::repeat_byte(1)); + let (block_2, _) = context.empty_block(&state_0, 2, H256::repeat_byte(2)); + + // Advance to epoch 2 — FCR variables rotate but no validator has voted. + context.on_slot(start_of_epoch(2)); + context.on_acceptable_block(&block_1); + context.on_acceptable_block(&block_2); + + let finalized = context.finalized_root(); + let confirmed = context.confirmed_root(); + + assert_eq!(confirmed, finalized); +} + +/// When FCR is enabled, `confirmed_root()` is more conservative than `justified_checkpoint().root`. +/// +/// Blocks are applied AFTER the epoch-3 tick has already run. When the tick fired: +/// - The slot-23 GU snapshot had captured genesis (no blocks in store yet). +/// - The epoch-3 rotation therefore set `fcr_curr_obs_justified = genesis`. +/// +/// After applying the blocks, `head.unrealized_justified_checkpoint.epoch = 2` (block_2 is an +/// epoch-2 block, so justification runs). But `obs = genesis ≠ head.unrealized_justified`, so +/// the FCR restart condition is false and `confirmed` stays at finalized. +/// +/// Meanwhile, `apply_block` promotes `justified_checkpoint` directly when the block is from a +/// prior epoch: block_2 (epoch 2) at tick slot 24 (epoch 3) qualifies, so +/// `justified_checkpoint.root` becomes the epoch-2 checkpoint root (block_1.root ≠ genesis). +#[test] +fn fcr_enabled_confirmed_is_more_conservative_than_justified() { + let _guard = FcrGuard::new(); + Feature::FastConfirmationRule.enable(); + + let mut context = Context::minimal(); + + let (_, state_0) = context.genesis(); + let (block_0b, state_0b) = context.empty_block(&state_0, 7, H256::repeat_byte(0)); + // block_1 at slot 15: becomes the epoch-2 checkpoint root (state.block_roots[16] = block_1.root). + let (block_1, state_1) = context.empty_block(&state_0b, 15, H256::repeat_byte(1)); + // block_2 at slot 22 with 6 epoch-2 attestation slots (75 % ≥ ⅔) — justifies epoch 2. + // block_2.unrealized_justified_checkpoint = { epoch: 2, root: block_1.root } + let (block_2, _) = context.block_with_attestations_for_slots( + &state_1, + 22, + 16..22, + H256::repeat_byte(2), + ); + + // Advance to epoch 3 FIRST (obs = genesis), then apply blocks. + // The epoch-3 tick sets fcr_curr_obs_justified from the slot-23 GU snapshot = genesis. + context.on_slot(start_of_epoch(3)); + context.on_acceptable_block(&block_0b); + context.on_acceptable_block(&block_1); + context.on_acceptable_block(&block_2); + + let justified_root = context.justified_checkpoint().root; + let finalized_root = context.finalized_root(); + let confirmed = context.confirmed_root(); + + assert_ne!( + justified_root, finalized_root, + "justification should have advanced the justified checkpoint", + ); + + assert_eq!( + confirmed, finalized_root, + "FCR confirmed root should remain at finalized when balance source is not yet rotated", + ); +} + +/// FCR advances `confirmed_root` beyond the finalized checkpoint when ≥ ⅔ of validators +/// have voted for the chain via attestations included in a block. +/// +/// **Why epoch-2 blocks are required:** +/// `process_justification_and_finalization` has an early return for epochs 0 and 1 +/// (`GENESIS_EPOCH + 1 < current_epoch` is false). Any block at epoch 0-1 therefore always +/// has `unrealized_justified_checkpoint = genesis`, which the FCR GU snapshot would capture. +/// With `fcr_curr_obs_justified.epoch = 0`, the restart condition +/// `obs.epoch + 1 == current_epoch` would be `1 == 2`, which is false. Advancing into epoch 2 +/// allows justification to be computed, the GU snapshot at slot 23 captures it, and the +/// epoch-3 restart fires successfully. +/// +/// **Epoch-2 checkpoint root:** +/// `state.block_roots[16]` is set by `process_slot` at the START of slot 16 to +/// `hash_tree_root(state.latest_block_header)` = block_1.root. So the FCR restart at epoch 3 +/// sets `confirmed_root = block_1.root` (the epoch-2 checkpoint block). +/// +/// Sequence: +/// 1. `on_slot(22)` — blocks applied here so that the slot-23 GU snapshot sees epoch-2 justification. +/// 2. Apply block_0b (slot 7), block_1 (slot 15), block_2 (slot 22, 6 epoch-2 attestation slots). +/// block_2's `unrealized_justified_checkpoint = { epoch: 2, root: block_1.root }`. +/// 3. `on_slot(23)` — last of epoch 2; GU snapshot captures `{ epoch: 2, root: block_1.root }`. +/// 4. `on_slot(24)` — epoch-3 start; rotation sets `fcr_curr_obs = { epoch: 2, root: block_1.root }`; +/// restart condition (`2 + 1 == 3`) fires; `confirmed_root` jumps to block_1. +#[test] +fn fcr_advances_beyond_finalized_with_supermajority() { + let _guard = FcrGuard::new(); + Feature::FastConfirmationRule.enable(); + + let mut context = Context::minimal(); + + let (_, state_0) = context.genesis(); + + // block_0b at slot 7: a non-genesis block so that subsequent checkpoint roots differ + // from genesis. + let (block_0b, state_0b) = + context.empty_block(&state_0, 7, H256::repeat_byte(1)); + + // block_1 at slot 15: becomes the epoch-2 checkpoint root. + // state.block_roots[16] is set by process_slot to block_1.root BEFORE any slot-16 block + // is processed, so the epoch-2 checkpoint root is always block_1.root. + let (block_1, state_1) = + context.empty_block(&state_0b, 15, H256::repeat_byte(2)); + + // block_2 at slot 22 with attestations for slots 16..22 (6 of 8 epoch-2 slots = 75 % ≥ ⅔). + // process_justification_and_finalization at epoch 2 (current_epoch > 1, so no early return) + // sees 75% epoch-2 participation and justifies epoch 2. + // → block_2.unrealized_justified_checkpoint = { epoch: 2, root: block_1.root } + let (block_2, _) = context.block_with_attestations_for_slots( + &state_1, + 22, + 16..22, + H256::repeat_byte(3), + ); + + // Advance to slot 22 and apply all three blocks so that store.unrealized_justified reflects + // epoch-2 justification before the slot-23 GU snapshot fires. + context.on_slot(22); + context.on_acceptable_block(&block_0b); + context.on_acceptable_block(&block_1); + context.on_acceptable_block(&block_2); + + // Slot 23: last slot of epoch 2 — GU snapshot captures epoch-2 justification. + context.on_slot(23); + + // Slot 24: epoch-3 start — rotation and FCR restart advance confirmed_root to block_1. + context.on_slot(start_of_epoch(3)); + + let finalized_root = context.finalized_root(); + let confirmed = context.confirmed_root(); + + assert_ne!( + confirmed, finalized_root, + "FCR confirmed root should advance beyond finalized with 75% epoch-2 attestation support", + ); + assert_eq!( + confirmed, + block_1.message().hash_tree_root(), + "FCR confirmed root should be block_1 — the epoch-2 checkpoint block", + ); +} + +/// FCR reverts `confirmed_root` to the finalized checkpoint when the confirmed block is +/// more than one epoch old (`confirmed_epoch + 1 < current_epoch`). +/// +/// Uses the same three-block setup as `fcr_advances_beyond_finalized_with_supermajority` +/// to establish FCR advancement to block_1 (epoch-1 slot, epoch-2 checkpoint) at epoch 3, +/// then verifies that advancing to epoch 4 triggers the age-revert condition: +/// confirmed_epoch (1) + 1 = 2 < current_epoch (4). +#[test] +fn fcr_reverts_to_finalized_when_confirmed_becomes_stale() { + let _guard = FcrGuard::new(); + Feature::FastConfirmationRule.enable(); + + let mut context = Context::minimal(); + + let (_, state_0) = context.genesis(); + let (block_0b, state_0b) = + context.empty_block(&state_0, 7, H256::repeat_byte(1)); + let (block_1, state_1) = + context.empty_block(&state_0b, 15, H256::repeat_byte(2)); + let (block_2, _) = context.block_with_attestations_for_slots( + &state_1, + 22, + 16..22, + H256::repeat_byte(3), + ); + + // Replicate the advancement setup from fcr_advances_beyond_finalized_with_supermajority. + context.on_slot(22); + context.on_acceptable_block(&block_0b); + context.on_acceptable_block(&block_1); + context.on_acceptable_block(&block_2); + context.on_slot(23); + context.on_slot(start_of_epoch(3)); + + let finalized_root = context.finalized_root(); + let confirmed_at_epoch_3 = context.confirmed_root(); + + assert_ne!( + confirmed_at_epoch_3, finalized_root, + "FCR should have advanced to block_1 at epoch-3 boundary (precondition for revert test)", + ); + + // Advance to epoch 4. The confirmed block (block_1) is at slot 15, epoch 1. + // The age-revert condition fires: confirmed_epoch (1) + 1 = 2 < current_epoch (4). + context.on_slot(start_of_epoch(4)); + + let confirmed_at_epoch_4 = context.confirmed_root(); + assert_eq!( + confirmed_at_epoch_4, finalized_root, + "FCR confirmed root should revert to finalized when confirmed is more than one epoch old", + ); +} diff --git a/fork_choice_control/src/helpers.rs b/fork_choice_control/src/helpers.rs index 9c858ee43..d6af5951e 100644 --- a/fork_choice_control/src/helpers.rs +++ b/fork_choice_control/src/helpers.rs @@ -162,6 +162,21 @@ impl Context

{ self.controller().last_finalized_state().value } + #[must_use] + pub fn confirmed_root(&self) -> H256 { + self.controller().confirmed_root() + } + + #[must_use] + pub fn finalized_root(&self) -> H256 { + self.controller().finalized_root() + } + + #[must_use] + pub fn justified_checkpoint(&self) -> types::phase0::containers::Checkpoint { + self.controller().justified_checkpoint() + } + // The `graffiti` parameters are needed for two reasons: // - To make otherwise identical blocks distinct. // - To break ties the desired way. @@ -274,6 +289,31 @@ impl Context

{ .expect("block should be constructed successfully") } + /// Creates a block at `block_slot` containing attestations for `attestation_slots`. + /// + /// Unlike [`Self::block_justifying_current_epoch`], the block slot and attestation range are + /// independent. This allows placing a justifying block at a slot earlier than the last slot of + /// the epoch so that the FCR GU snapshot (taken at the last slot's tick) can capture the + /// updated `unrealized_justified_checkpoint`. + #[must_use] + pub fn block_with_attestations_for_slots( + &self, + pre_state: &Arc>, + block_slot: Slot, + attestation_slots: core::ops::Range, + graffiti: H256, + ) -> (Arc>, Arc>) { + factory::block_with_attestations_for_slots( + self.config(), + &self.pubkey_cache, + pre_state.clone_arc(), + block_slot, + attestation_slots, + graffiti, + ) + .expect("block should be constructed successfully") + } + pub fn on_tick(&mut self, tick: Tick) { let old_slot = self.controller().slot(); let new_slot = tick.slot; diff --git a/fork_choice_control/src/queries.rs b/fork_choice_control/src/queries.rs index 1124e5130..a5c2a0a7f 100644 --- a/fork_choice_control/src/queries.rs +++ b/fork_choice_control/src/queries.rs @@ -5,6 +5,7 @@ use anyhow::{Result, bail, ensure}; use arc_swap::Guard; use eth2_libp2p::GossipId; use execution_engine::ExecutionEngine; +use features::Feature; use fork_choice_store::{ AggregateAndProofOrigin, AttestationItem, BlobSidecarAction, BlobSidecarOrigin, ChainLink, DataColumnSidecarAction, DataColumnSidecarOrigin, StateCacheProcessor, Store, @@ -83,6 +84,13 @@ where self.store_snapshot().finalized_root() } + /// Returns the current FCR-confirmed block root, or the justified checkpoint root when FCR + /// is disabled. + #[must_use] + pub fn confirmed_root(&self) -> H256 { + self.store_snapshot().confirmed_root() + } + #[must_use] pub fn genesis_time(&self) -> UnixSeconds { let store = self.store_snapshot(); @@ -231,10 +239,19 @@ where }) .collect(); + let extra_data = Feature::FastConfirmationRule.is_enabled().then(|| FcrExtraData { + confirmed_root: store.confirmed_root(), + current_epoch_observed_justified_checkpoint: store.fcr_curr_obs_justified(), + previous_epoch_greatest_unrealized_checkpoint: store.fcr_prev_gu_checkpoint(), + previous_slot_head: store.fcr_prev_slot_head(), + current_slot_head: store.fcr_curr_slot_head(), + }); + ForkChoiceContext { justified_checkpoint: store.justified_checkpoint(), finalized_checkpoint: store.finalized_checkpoint(), fork_choice_nodes, + extra_data, } } @@ -1039,6 +1056,19 @@ pub struct ForkChoiceContext { justified_checkpoint: Checkpoint, finalized_checkpoint: Checkpoint, fork_choice_nodes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + extra_data: Option, +} + +/// FCR internal state exposed on `GET /eth/v1/debug/fork_choice`. +/// Only serialized when `Feature::FastConfirmationRule` is enabled. +#[derive(Serialize)] +struct FcrExtraData { + confirmed_root: H256, + current_epoch_observed_justified_checkpoint: Checkpoint, + previous_epoch_greatest_unrealized_checkpoint: Checkpoint, + previous_slot_head: H256, + current_slot_head: H256, } #[derive(Serialize)] diff --git a/fork_choice_store/src/fast_confirmation.rs b/fork_choice_store/src/fast_confirmation.rs new file mode 100644 index 000000000..b01c52d2d --- /dev/null +++ b/fork_choice_store/src/fast_confirmation.rs @@ -0,0 +1,427 @@ +/// Fast Confirmation Rule (FCR) constants, types, and free functions. +/// +/// See the [FCR specification](https://github.com/ethereum/consensus-specs/blob/main/specs/phase0/fast-confirmation.md). + +use arithmetic::NonZeroExt as _; +use helper_functions::misc; +use typenum::Unsigned as _; +use types::{ + phase0::{ + containers::Checkpoint, + primitives::{Epoch, Gwei, H256, Slot}, + }, + preset::Preset, +}; + +/// Assumed maximum percentage of Byzantine validators among the validator set. +pub const CONFIRMATION_BYZANTINE_THRESHOLD: u64 = 25; + +/// Per-mille value added to committee weight estimates for ranges not covering a full epoch, +/// to ensure safety with high probability. +/// +/// See . +pub const COMMITTEE_WEIGHT_ESTIMATION_ADJUSTMENT_FACTOR: u64 = 5; + +/// Pre-computed per-block data built by `Store::fcr_build_chain_info`. +#[derive(Debug, Clone)] +pub struct ChainInfo { + pub block_root: H256, + pub slot: Slot, + /// Slot of this block's parent. + pub parent_slot: Slot, + /// Epoch of this block's slot. + pub epoch: Epoch, + /// Voting source epoch: `unrealized_justified_checkpoint.epoch` for prev-epoch blocks, + /// `store.justified_checkpoint.epoch` for current-epoch blocks. + pub voting_source_epoch: Epoch, + /// Whether `store.fcr_prev_slot_head` has this block as an ancestor. + pub seen_by_prev_head: bool, + /// spec: `get_attestation_score` — total active balance of validators whose latest + /// message is a descendant of this block. + pub support: Gwei, + /// spec: `get_equivocation_score` — total balance of equivocating validators assigned + /// to committees in the adversarial slot range for this block. + pub adversarial: Gwei, + /// Maximum possible committee weight from `parent_slot + 1` to `current_slot - 1`. + /// Used as `maximum_support` in `compute_safety_threshold`. + pub committee_weight: Gwei, + /// Maximum possible committee weight for the adversarial slot range + /// (`adv_start` to `current_slot - 1`). Used in `compute_adversarial_weight`. + pub adv_committee_weight: Gwei, + /// spec: `compute_proposer_score`. + pub proposer_score: Gwei, + /// spec: `compute_empty_slot_support_discount` — pre-computed because it needs + /// `get_block_support_between_slots` for the parent in empty slots. + pub support_discount: Gwei, +} + +/// FFG-related state built by `Store::fcr_build_ffg_data`. +#[derive(Debug, Clone)] +pub struct FcrFfgData { + /// Cached minimum honest FFG support for the current epoch target. + pub honest_ffg_support: Gwei, + /// Cached total active balance from the current balance source. + pub total_active_balance: Gwei, + pub prev_obs_justified: Checkpoint, + pub curr_obs_justified: Checkpoint, + /// The store's current `unrealized_justified_checkpoint` (for the spec shortcut). + pub unrealized_justified_checkpoint: Checkpoint, + /// Ancestor of the current head at the current epoch start slot. + pub current_target: Option, + /// spec: `get_current_target_score` — pre-computed by `Store::fcr_build_ffg_data` + /// because it requires `latest_messages` and `ancestor()`. + pub current_target_score: Gwei, + /// Committee weight from epoch start to `current_slot - 1` (used by + /// `compute_honest_ffg_support_for_current_target`). + pub ffg_weight_till_now: Gwei, + /// Adversarial weight from epoch start to `current_slot - 1`. + pub adversarial_this_epoch: Gwei, +} + +/// Diagnostic data for a block that failed `is_one_confirmed`. +#[derive(Debug, Clone)] +pub struct FcrDiagnostics { + pub block_root: H256, + pub slot: Slot, + pub support: Gwei, + pub threshold: Gwei, + pub reason: &'static str, +} + +// ============================================================ +// spec: `estimate_committee_weight_between_slots` +// ============================================================ + +/// spec: `estimate_committee_weight_between_slots(total_active_balance, start_slot, end_slot)` +pub fn estimate_committee_weight_between_slots( + total_active_balance: Gwei, + start_slot: Slot, + end_slot: Slot, +) -> Gwei { + if start_slot > end_slot { + return 0; + } + + // is_full_validator_set_covered: does the range contain a complete epoch? + // spec: start_full_epoch = epoch(start_slot + SLOTS_PER_EPOCH - 1) + // end_full_epoch = epoch(end_slot + 1) + // covered if start_full_epoch < end_full_epoch + let spe = P::SlotsPerEpoch::U64; + let start_full_epoch = + misc::compute_epoch_at_slot::

(start_slot.saturating_add(spe - 1)); + let end_full_epoch = misc::compute_epoch_at_slot::

(end_slot.saturating_add(1)); + if start_full_epoch < end_full_epoch { + return total_active_balance; + } + + let start_epoch = misc::compute_epoch_at_slot::

(start_slot); + let end_epoch = misc::compute_epoch_at_slot::

(end_slot); + let committee_weight = total_active_balance / P::SlotsPerEpoch::non_zero(); + + if start_epoch == end_epoch { + committee_weight * (end_slot - start_slot + 1) + } else { + // Spans epoch boundary but doesn't cover a full epoch — pro-rata calculation + let num_slots_in_end_epoch = misc::slots_since_epoch_start::

(end_slot) + 1; + let remaining_slots_in_end_epoch = spe - num_slots_in_end_epoch; + let num_slots_in_start_epoch = spe - misc::slots_since_epoch_start::

(start_slot); + + let start_epoch_weight = committee_weight * num_slots_in_start_epoch; + let end_epoch_weight = committee_weight * num_slots_in_end_epoch; + + // pro-rated: start_epoch_weight * remaining_in_end / SLOTS_PER_EPOCH + let start_epoch_weight_pro_rated = + start_epoch_weight / spe * remaining_slots_in_end_epoch; + + let raw_estimate = start_epoch_weight_pro_rated + end_epoch_weight; + + // adjust_committee_weight_estimate_to_ensure_safety: + // estimate * (1000 + ADJUSTMENT_FACTOR) / 1000 + raw_estimate / 1000 * (1000 + COMMITTEE_WEIGHT_ESTIMATION_ADJUSTMENT_FACTOR) + } +} + +// ============================================================ +// spec: `compute_proposer_score` +// ============================================================ + +/// spec: `compute_proposer_score(balance_source)` +pub fn compute_proposer_score( + total_active_balance: Gwei, + proposer_score_boost: u64, +) -> Gwei { + let committee_weight = total_active_balance / P::SlotsPerEpoch::non_zero(); + committee_weight * proposer_score_boost / 100 +} + +// ============================================================ +// spec: `compute_adversarial_weight` +// ============================================================ + +/// spec: `compute_adversarial_weight(store, balance_source, start_slot, end_slot)` +/// +/// `adv_committee_weight` — estimated committee weight for the adversarial slot range +/// (pre-computed into `ChainInfo.adv_committee_weight` by `fcr_build_chain_info`). +/// +/// `equivocation_score` — `ChainInfo.adversarial` (pre-computed equivocating balance +/// in that range by `fcr_build_chain_info`). +pub fn compute_adversarial_weight(adv_committee_weight: Gwei, equivocation_score: Gwei) -> Gwei { + let max_adversarial = adv_committee_weight / 100 * CONFIRMATION_BYZANTINE_THRESHOLD; + max_adversarial.saturating_sub(equivocation_score) +} + +// ============================================================ +// spec: `compute_empty_slot_support_discount` +// ============================================================ + +/// spec: `compute_empty_slot_support_discount(store, balance_source, block_root)` +/// +/// Uses `ChainInfo.support_discount` which is pre-computed in `fcr_build_chain_info` +/// because computing it requires `get_block_support_between_slots` for the parent block +/// (needs `latest_messages` and `beacon_committees`). +/// +/// This function exists for spec traceability; the actual computation happens during +/// pre-computation. +#[inline] +pub fn compute_empty_slot_support_discount(info: &ChainInfo) -> Gwei { + info.support_discount +} + +// ============================================================ +// spec: `compute_safety_threshold` +// ============================================================ + +/// spec: `compute_safety_threshold(store, block_root, balance_source)` +/// +/// Returns `(maximum_support + proposer_score + 2*adversarial_weight - support_discount) / 2`. +pub fn compute_safety_threshold(info: &ChainInfo) -> Gwei { + let adversarial_weight = compute_adversarial_weight(info.adv_committee_weight, info.adversarial); + let support_discount = compute_empty_slot_support_discount(info); + + let lhs = info + .committee_weight + .saturating_add(info.proposer_score) + .saturating_add(2 * adversarial_weight); + + if support_discount < lhs { + (lhs - support_discount) / 2 + } else { + 0 + } +} + +// ============================================================ +// spec: `is_one_confirmed` +// ============================================================ + +/// spec: `is_one_confirmed(store, balance_source, block_root)` +pub fn is_one_confirmed(info: &ChainInfo) -> bool { + info.support > compute_safety_threshold(info) +} + +// ============================================================ +// spec: `get_voting_source_for_root` (helper absorbed into ChainInfo) +// ============================================================ + +/// spec: `get_voting_source(store, block_root)` +/// +/// Returns the voting source epoch for a block. +/// Called by `fcr_build_chain_info` to populate `ChainInfo.voting_source_epoch`; +/// also callable standalone. +pub fn get_voting_source_epoch( + block_epoch: Epoch, + current_epoch: Epoch, + unrealized_justified_epoch: Epoch, + store_justified_epoch: Epoch, +) -> Epoch { + if block_epoch < current_epoch { + unrealized_justified_epoch + } else { + store_justified_epoch + } +} + +// ============================================================ +// spec: `compute_honest_ffg_support_for_current_target` +// ============================================================ + +/// spec: `compute_honest_ffg_support_for_current_target(store)` +/// +/// `FcrFfgData.current_target_score`, `ffg_weight_till_now`, and `adversarial_this_epoch` +/// are pre-computed in `Store::fcr_build_ffg_data` because they require `latest_messages` +/// and `ancestor()`. +pub fn compute_honest_ffg_support_for_current_target(ffg: &FcrFfgData) -> Gwei { + let remaining_ffg_weight = ffg + .total_active_balance + .saturating_sub(ffg.ffg_weight_till_now); + + let remaining_honest_ffg_weight = + remaining_ffg_weight / 100 * (100 - CONFIRMATION_BYZANTINE_THRESHOLD); + + let min_honest_ffg_support = ffg + .current_target_score + .saturating_sub(ffg.adversarial_this_epoch.min(ffg.current_target_score)); + + min_honest_ffg_support + remaining_honest_ffg_weight +} + +// ============================================================ +// spec: `will_no_conflicting_checkpoint_be_justified` +// ============================================================ + +/// spec: `will_no_conflicting_checkpoint_be_justified(store)` +/// +/// Returns `true` when `3 * honest_ffg_support >= total_active_balance`. +/// Short-circuits when the current target is already the unrealized justified checkpoint. +pub fn will_no_conflicting_checkpoint_be_justified(ffg: &FcrFfgData) -> bool { + // Spec shortcut: if current target IS the unrealized justified, no conflict is possible + if let Some(current_target) = ffg.current_target { + if current_target == ffg.unrealized_justified_checkpoint { + return true; + } + } + + if ffg.total_active_balance == 0 { + return false; + } + 3 * ffg.honest_ffg_support >= ffg.total_active_balance +} + +// ============================================================ +// spec: `will_current_target_be_justified` +// ============================================================ + +/// spec: `will_current_target_be_justified(store)` +/// +/// Returns `true` when `3 * honest_ffg_support >= 2 * total_active_balance`. +pub fn will_current_target_be_justified(ffg: &FcrFfgData) -> bool { + if ffg.total_active_balance == 0 { + return false; + } + 3 * ffg.honest_ffg_support >= 2 * ffg.total_active_balance +} + +// ============================================================ +// spec: `is_confirmed_chain_safe` +// ============================================================ + +/// spec: `is_confirmed_chain_safe(store, confirmed_root)` +/// +/// `chain` must be built with the PREVIOUS epoch balance source. +pub fn is_confirmed_chain_safe(chain: &[ChainInfo]) -> bool { + chain.iter().all(is_one_confirmed) +} + +// ============================================================ +// spec: `find_latest_confirmed_descendant` +// ============================================================ + +/// spec: `find_latest_confirmed_descendant(store, latest_confirmed_root)` +/// +/// - `chain`: blocks from `confirmed_root` (exclusive) toward head (inclusive), oldest-first. +/// - `confirmed_epoch`: epoch of the initial `confirmed_root`. +/// - `current_slot_is_epoch_start`: `misc::is_epoch_start(current_slot)`. +/// - `prev_head_voting_source_epoch`: `ChainInfo.voting_source_epoch` of `store.fcr_prev_slot_head`. +/// - `prev_head_unrealized_justified_epoch`: unrealized justified epoch of `store.fcr_prev_slot_head`. +/// - `head_unrealized_justified_epoch`: unrealized justified epoch of the current head. +pub fn find_latest_confirmed_descendant( + chain: &[ChainInfo], + confirmed_root: H256, + confirmed_epoch: Epoch, + current_epoch: Epoch, + current_slot_is_epoch_start: bool, + ffg: &FcrFfgData, + prev_head_voting_source_epoch: Epoch, + prev_head_unrealized_justified_epoch: Epoch, + head_unrealized_justified_epoch: Epoch, +) -> H256 { + let mut confirmed_root = confirmed_root; + let mut confirmed_epoch = confirmed_epoch; + + // ---- Loop 1 guard ---- + // Advance through previous-epoch blocks when: + // - confirmed is from previous epoch + // - previous_slot_head's voting source is recent (epoch+2 >= current) + // - FFG condition: epoch start OR (no conflicting justification can happen AND + // either the previous head or current head unrealized-justifies the previous epoch) + let loop1_guard = confirmed_epoch + 1 == current_epoch + && prev_head_voting_source_epoch + 2 >= current_epoch + && (current_slot_is_epoch_start + || (will_no_conflicting_checkpoint_be_justified(ffg) + && (prev_head_unrealized_justified_epoch + 1 >= current_epoch + || head_unrealized_justified_epoch + 1 >= current_epoch))); + + if loop1_guard { + for info in chain { + // Stop at current epoch — Loop 2 handles those + if info.epoch == current_epoch { + break; + } + + // The previous head must have seen this block + if !info.seen_by_prev_head { + break; + } + + if !is_one_confirmed(info) { + break; + } + + confirmed_root = info.block_root; + confirmed_epoch = info.epoch; + } + } + + // ---- Loop 2 guard ---- + // Advance through current-epoch blocks when: + // - it's the epoch start, OR + // - the head's unrealized justification covers the previous epoch + let loop2_guard = + current_slot_is_epoch_start || head_unrealized_justified_epoch + 1 >= current_epoch; + + if loop2_guard { + // Start from after the block Loop 1 advanced to (or from beginning if no advancement) + let loop2_start = chain + .iter() + .position(|c| c.block_root == confirmed_root) + .map(|i| i + 1) + .unwrap_or(0); + + let mut tentative_root = confirmed_root; + let mut tentative_epoch = confirmed_epoch; + + for info in &chain[loop2_start..] { + // First block crossing into current epoch: need FFG guarantee + if info.epoch > tentative_epoch && info.epoch == current_epoch { + if !will_current_target_be_justified(ffg) { + break; + } + } + + if !is_one_confirmed(info) { + break; + } + + tentative_root = info.block_root; + tentative_epoch = info.epoch; + } + + // Final gate: can we commit to tentative_root? + let tentative_vs_epoch = chain + .iter() + .find(|c| c.block_root == tentative_root) + .map(|c| c.voting_source_epoch) + .unwrap_or(0); + + let can_advance = tentative_epoch == current_epoch + || (tentative_vs_epoch + 2 >= current_epoch + && (current_slot_is_epoch_start + || will_no_conflicting_checkpoint_be_justified(ffg))); + + if can_advance { + confirmed_root = tentative_root; + } + } + + confirmed_root +} diff --git a/fork_choice_store/src/lib.rs b/fork_choice_store/src/lib.rs index d9292905e..7cba6e143 100644 --- a/fork_choice_store/src/lib.rs +++ b/fork_choice_store/src/lib.rs @@ -95,6 +95,7 @@ pub use crate::{ mod blob_cache; mod data_column_cache; mod error; +pub mod fast_confirmation; mod misc; mod segment; mod state_cache_processor; diff --git a/fork_choice_store/src/store.rs b/fork_choice_store/src/store.rs index b21d0c3bf..7b6fda02d 100644 --- a/fork_choice_store/src/store.rs +++ b/fork_choice_store/src/store.rs @@ -16,7 +16,7 @@ use bitvec::vec::BitVec; use anyhow::{Result, anyhow, bail, ensure}; use arithmetic::NonZeroExt as _; use bls::traits::SignatureBytes as _; -use clock::Tick; +use clock::{Tick, TickKind}; use eip_7594::{verify_data_column_sidecar, verify_kzg_proofs, verify_sidecar_inclusion_proof}; use execution_engine::ExecutionEngine; use features::Feature; @@ -38,7 +38,7 @@ use scc::HashMap as SccHashMap; use ssz::{ContiguousList, SszHash as _}; use std_ext::ArcExt as _; use tap::Pipe as _; -use tracing::{debug_span, instrument}; +use tracing::{debug, debug_span, instrument}; use transition_functions::{ combined, unphased::{self, ProcessSlots, StateRootPolicy}, @@ -85,6 +85,7 @@ use crate::{ blob_cache::BlobCache, data_column_cache::DataColumnCache, error::Error, + fast_confirmation, misc::{ AggregateAndProofAction, AggregateAndProofOrigin, ApplyBlockChanges, ApplyTickChanges, AttestationAction, AttestationItem, AttestationOrigin, AttestationValidationError, @@ -268,6 +269,25 @@ pub struct Store> { delayed_block_at_slot: HashMap, requested_blobs_from_el: HashMap, current_slot_blocks_in_processing: Arc, + // === Fast Confirmation Rule (FCR) fields === + // Only meaningful when `Feature::FastConfirmationRule` is enabled. + // spec: `confirmed_root` + fcr_confirmed_root: H256, + // spec: `previous_epoch_observed_justified_checkpoint` + fcr_prev_obs_justified: Checkpoint, + // spec: `current_epoch_observed_justified_checkpoint` + fcr_curr_obs_justified: Checkpoint, + // spec: `previous_epoch_greatest_unrealized_checkpoint` + fcr_prev_gu_checkpoint: Checkpoint, + // spec: `previous_slot_head` + fcr_prev_slot_head: H256, + // spec: `current_slot_head` + fcr_curr_slot_head: H256, + // Cached result of compute_honest_ffg_support_for_current_target, refreshed each slot. + // Avoids redundant validator-set scans when the FFG gate is queried multiple times. + fcr_honest_ffg_support: Gwei, + // Cached total active balance from the current balance source, refreshed each slot. + fcr_ffg_total_active_balance: Gwei, } impl> Store { @@ -364,6 +384,15 @@ impl> Store { delayed_block_at_slot: HashMap::default(), requested_blobs_from_el: HashMap::default(), current_slot_blocks_in_processing: Arc::new(AtomicUsize::new(0)), + // FCR: initialize confirmed_root to anchor (= finalized) block root + fcr_confirmed_root: block_root, + fcr_prev_obs_justified: checkpoint, + fcr_curr_obs_justified: checkpoint, + fcr_prev_gu_checkpoint: checkpoint, + fcr_prev_slot_head: block_root, + fcr_curr_slot_head: block_root, + fcr_honest_ffg_support: 0, + fcr_ffg_total_active_balance: 0, } } @@ -1048,11 +1077,56 @@ impl> Store { /// [`get_safe_execution_payload_hash`](https://github.com/ethereum/consensus-specs/blob/v1.3.0/fork_choice/safe-block.md#get_safe_execution_payload_hash) #[must_use] pub fn safe_execution_payload_hash(&self) -> ExecutionBlockHash { + if Feature::FastConfirmationRule.is_enabled() && self.fcr_confirmed_root != H256::zero() { + if let Some(hash) = self + .chain_link(self.fcr_confirmed_root) + .and_then(ChainLink::execution_block_hash) + { + return hash; + } + } self.justified_chain_link() .and_then(ChainLink::execution_block_hash) .unwrap_or_default() } + /// Returns the current confirmed block root. + /// + /// When `Feature::FastConfirmationRule` is enabled, this is the FCR-confirmed root. + /// Otherwise falls back to the justified checkpoint root. + #[must_use] + pub fn confirmed_root(&self) -> H256 { + if Feature::FastConfirmationRule.is_enabled() { + self.fcr_confirmed_root + } else { + self.justified_checkpoint.root + } + } + + /// Returns `store.current_epoch_observed_justified_checkpoint`. + #[must_use] + pub fn fcr_curr_obs_justified(&self) -> Checkpoint { + self.fcr_curr_obs_justified + } + + /// Returns `store.previous_epoch_greatest_unrealized_checkpoint`. + #[must_use] + pub fn fcr_prev_gu_checkpoint(&self) -> Checkpoint { + self.fcr_prev_gu_checkpoint + } + + /// Returns `store.previous_slot_head`. + #[must_use] + pub fn fcr_prev_slot_head(&self) -> H256 { + self.fcr_prev_slot_head + } + + /// Returns `store.current_slot_head`. + #[must_use] + pub fn fcr_curr_slot_head(&self) -> H256 { + self.fcr_curr_slot_head + } + #[must_use] pub fn finalized_execution_payload_hash(&self) -> ExecutionBlockHash { // > As per EIP-3675, before a post-transition block is finalized, @@ -2838,6 +2912,11 @@ impl> Store { self.prune_after_finalization(); } + // FCR: run once per slot after attestations are applied and head is updated. + if Feature::FastConfirmationRule.is_enabled() { + self.fcr_on_fast_confirmation(); + } + self.blob_cache.on_slot(new_tick.slot); self.reset_current_slot_blocks_in_processing(); @@ -2982,6 +3061,21 @@ impl> Store { self.prune_after_finalization(); } + // FCR: advance confirmed when head changes. + // Note: fcr_update_fast_confirmation_variables is NOT called here — it runs once per + // slot in apply_tick. This path only re-runs get_latest_confirmed when the head moves. + if Feature::FastConfirmationRule.is_enabled() { + let new_head_root = self.head().block_root; + if new_head_root != old_head.block_root { + let new_confirmed = self.fcr_get_latest_confirmed(); + self.fcr_confirmed_root = new_confirmed; + } + // Track current_slot_head: update if block arrives before attestation deadline. + if self.tick.kind < TickKind::Attest { + self.fcr_curr_slot_head = self.head().block_root; + } + } + if !self.finished_initial_forward_sync && self.head().slot() >= self.slot() { self.finished_initial_forward_sync = true; self.state_cache.set_log_lock_timeouts(true); @@ -4659,4 +4753,609 @@ impl> Store { .sum(), ); } + + // ========================================================================= + // Fast Confirmation Rule (FCR) — spec: fast-confirmation.md + // ========================================================================= + + /// spec: `get_equivocation_score(store, state, from_slot, to_slot)` + /// + /// Sums the active balances of validators that both appear in a committee + /// between `from_slot..=to_slot` AND are in `self.equivocating_indices`. + /// + /// Callers are responsible for the short-circuit when `equivocating_indices` is empty. + fn fcr_get_equivocation_score( + &self, + state: &BeaconState

, + from_slot: Slot, + to_slot: Slot, + active_balances: &[Gwei], + ) -> Gwei { + let mut eq_set: StdHashSet = StdHashSet::new(); + for s in from_slot..=to_slot { + if let Ok(committees) = accessors::beacon_committees::

(state, s) { + for committee in committees { + for vi in committee { + if self.equivocating_indices.contains(&vi) { + eq_set.insert(vi); + } + } + } + } + } + eq_set + .iter() + .filter_map(|&i| active_balances.get(i as usize).copied()) + .filter(|&b| b > 0) + .sum() + } + + /// spec: `get_attestation_score(store, store_target, active_balances)` + /// + /// Sums the active balances of validators whose latest message descends through + /// `root` at `slot`. + fn fcr_get_attestation_score( + &self, + root: H256, + slot: Slot, + active_balances: &[Gwei], + ) -> Gwei { + self.latest_messages + .iter() + .enumerate() + .filter_map(|(i, msg_opt)| { + let msg = msg_opt.as_ref()?; + let balance = *active_balances.get(i)?; + if balance == 0 { + return None; + } + if self.equivocating_indices.contains(&(i as ValidatorIndex)) { + return None; + } + if !self.contains_block(msg.beacon_block_root) { + return None; + } + let anc = self.ancestor(msg.beacon_block_root, slot)?; + if anc != root { + return None; + } + Some(balance) + }) + .sum() + } + + /// spec: `compute_empty_slot_support_discount(store, state, block, active_balances)` + /// + /// Computes the support discount applied when there are empty slots between + /// `parent_slot` and `slot`. Returns 0 immediately when the slots are adjacent. + fn fcr_precompute_support_discount( + &self, + parent_slot: Slot, + slot: Slot, + parent_root: H256, + state: &BeaconState

, + active_balances: &[Gwei], + total_active_balance: Gwei, + ) -> Gwei { + if parent_slot + 1 == slot { + return 0; + } + + let empty_start = parent_slot + 1; + let empty_end = slot - 1; + + // spec: get_block_support_between_slots for parent in empty slots + let mut participants: StdHashSet = StdHashSet::new(); + for s in empty_start..=empty_end { + if let Ok(committees) = accessors::beacon_committees::

(state, s) { + for committee in committees { + for vi in committee { + participants.insert(vi); + } + } + } + } + let parent_support_in_empty: Gwei = participants + .iter() + .filter_map(|&i| { + let balance = *active_balances.get(i as usize)?; + if balance == 0 { + return None; + } + if self.equivocating_indices.contains(&i) { + return None; + } + let msg = self.latest_messages.get(i as usize)?.as_ref()?; + if msg.beacon_block_root != parent_root { + return None; + } + Some(balance) + }) + .sum(); + + let empty_committee_weight = + fast_confirmation::estimate_committee_weight_between_slots::

( + total_active_balance, + empty_start, + empty_end, + ); + let empty_equivocation: Gwei = if self.equivocating_indices.is_empty() { + 0 + } else { + self.fcr_get_equivocation_score(state, empty_start, empty_end, active_balances) + }; + let adv_empty = fast_confirmation::compute_adversarial_weight( + empty_committee_weight, + empty_equivocation, + ); + parent_support_in_empty.saturating_sub(adv_empty) + } + + /// Builds per-block pre-computed data for the chain from `terminal_root` (exclusive) + /// to `block_root` (inclusive), oldest-first. + /// + /// This is the single O(validators) pre-computation pass. It stays on `Store` + /// because it needs `self.latest_messages`, `self.equivocating_indices`, + /// `self.checkpoint_states`, and `accessors::beacon_committees`. + fn fcr_build_chain_info( + &self, + block_root: H256, + terminal_root: H256, + balance_source: Option<&BeaconState

>, + active_balances: &[Gwei], + total_active_balance: Gwei, + ) -> Vec { + // Collect ancestor chain oldest-first (terminal exclusive, block_root inclusive) + let terminal_slot = match self.chain_link(terminal_root) { + Some(cl) => cl.slot(), + None => { + debug!(?terminal_root, "FCR: terminal root not found in store"); + return vec![]; + } + }; + + let mut roots = Vec::new(); + let mut current = block_root; + loop { + let cl = match self.chain_link(current) { + Some(cl) => cl, + None => { + debug!(?current, "FCR: ancestor not found in store while building chain"); + return vec![]; + } + }; + if cl.slot() <= terminal_slot { + debug!(slot = cl.slot(), terminal_slot, "FCR: chain reached terminal slot"); + return vec![]; + } + roots.push(current); + let parent = cl.block.message().parent_root(); + if parent == terminal_root { + roots.reverse(); + break; + } + current = parent; + } + + let current_slot = self.slot(); + let current_epoch = self.current_epoch(); + let proposer_score_boost = self.chain_config.proposer_score_boost; + let end_slot = if current_slot > 0 { current_slot - 1 } else { 0 }; + + roots + .into_iter() + .filter_map(|root| { + let cl = self.chain_link(root)?; + let slot = cl.slot(); + let parent_root = cl.block.message().parent_root(); + let parent_slot = match self.chain_link(parent_root) { + Some(cl) => cl.slot(), + None => { + debug!(?root, ?parent_root, "FCR: parent not in store mid-chain, skipping block"); + return None; + } + }; + let epoch = misc::compute_epoch_at_slot::

(slot); + let parent_epoch = misc::compute_epoch_at_slot::

(parent_slot); + + let voting_source_epoch = fast_confirmation::get_voting_source_epoch( + epoch, + current_epoch, + cl.unrealized_justified_checkpoint.epoch, + self.justified_checkpoint.epoch, + ); + + let seen_by_prev_head = self + .ancestor(self.fcr_prev_slot_head, slot) + .is_some_and(|a| a == root); + + // spec: get_attestation_score + let support = self.fcr_get_attestation_score(root, slot, active_balances); + + // Adversarial slot range start: epoch start if block crosses epoch boundary + let adv_start_slot = if epoch > parent_epoch { + misc::compute_start_slot_at_epoch::

(epoch) + } else { + slot + }; + + let committee_weight = if current_slot > 0 { + fast_confirmation::estimate_committee_weight_between_slots::

( + total_active_balance, + parent_slot + 1, + end_slot, + ) + } else { + 0 + }; + + let adv_committee_weight = if current_slot > 0 { + fast_confirmation::estimate_committee_weight_between_slots::

( + total_active_balance, + adv_start_slot, + end_slot, + ) + } else { + 0 + }; + + // spec: get_equivocation_score for adv_start..=end_slot + let adversarial: Gwei = if current_slot > 0 { + balance_source + .filter(|_| !self.equivocating_indices.is_empty()) + .map(|state| { + self.fcr_get_equivocation_score( + state, + adv_start_slot, + end_slot, + active_balances, + ) + }) + .unwrap_or(0) + } else { + 0 + }; + + let proposer_score = fast_confirmation::compute_proposer_score::

( + total_active_balance, + proposer_score_boost, + ); + + // spec: compute_empty_slot_support_discount + let support_discount: Gwei = balance_source + .map(|state| { + self.fcr_precompute_support_discount( + parent_slot, + slot, + parent_root, + state, + active_balances, + total_active_balance, + ) + }) + .unwrap_or(0); + + Some(fast_confirmation::ChainInfo { + block_root: root, + slot, + parent_slot, + epoch, + voting_source_epoch, + seen_by_prev_head, + support, + adversarial, + committee_weight, + adv_committee_weight, + proposer_score, + support_discount, + }) + }) + .collect() + } + + /// Builds `FcrFfgData` from the per-slot FFG cache and current store state. + /// + /// `get_current_target_score` stays here because it requires `self.latest_messages` + /// and `self.ancestor()`. + fn fcr_build_ffg_data( + &self, + active_balances: &[Gwei], + total_active_balance: Gwei, + ) -> fast_confirmation::FcrFfgData { + let current_slot = self.slot(); + let current_epoch = self.current_epoch(); + let epoch_start = misc::compute_start_slot_at_epoch::

(current_epoch); + + let head = self.head(); + + // current_target for the spec shortcut in will_no_conflicting_checkpoint_be_justified + let current_target = self.ancestor(head.block_root, epoch_start).map(|root| Checkpoint { + epoch: current_epoch, + root, + }); + + // spec: get_current_target_score — stays on Store (needs latest_messages + ancestor) + let current_target_root = current_target.map(|cp| cp.root); + let current_target_score: Gwei = current_target_root + .map(|target_root| { + self.latest_messages + .iter() + .enumerate() + .filter_map(|(i, msg_opt)| { + let msg = msg_opt.as_ref()?; + let balance = *active_balances.get(i)?; + if balance == 0 { return None; } + if self.equivocating_indices.contains(&(i as ValidatorIndex)) { return None; } + if msg.epoch != current_epoch { return None; } + if !self.contains_block(msg.beacon_block_root) { return None; } + let vote_target = self.ancestor(msg.beacon_block_root, epoch_start)?; + if vote_target != target_root { return None; } + Some(balance) + }) + .sum() + }) + .unwrap_or(0); + + let ffg_weight_till_now = if current_slot > 0 { + fast_confirmation::estimate_committee_weight_between_slots::

( + total_active_balance, + epoch_start, + current_slot - 1, + ) + } else { + 0 + }; + + let adversarial_this_epoch: Gwei = if current_slot > 0 + && !self.equivocating_indices.is_empty() + { + match self + .checkpoint_states + .get(&self.fcr_curr_obs_justified) + .map(|arc| arc.as_ref()) + { + Some(state) => { + let adv_weight = fast_confirmation::estimate_committee_weight_between_slots::

( + total_active_balance, + epoch_start, + current_slot - 1, + ); + let max_adversarial = + adv_weight / 100 * fast_confirmation::CONFIRMATION_BYZANTINE_THRESHOLD; + let eq_score = self.fcr_get_equivocation_score( + state, + epoch_start, + current_slot - 1, + active_balances, + ); + max_adversarial.saturating_sub(eq_score) + } + None => { + debug!( + checkpoint = ?self.fcr_curr_obs_justified, + "FCR: no checkpoint state for curr_obs_justified, equivocation penalty zeroed" + ); + 0 + } + } + } else { + 0 + }; + + fast_confirmation::FcrFfgData { + honest_ffg_support: self.fcr_honest_ffg_support, + total_active_balance, + prev_obs_justified: self.fcr_prev_obs_justified, + curr_obs_justified: self.fcr_curr_obs_justified, + unrealized_justified_checkpoint: self.unrealized_justified_checkpoint, + current_target, + current_target_score, + ffg_weight_till_now, + adversarial_this_epoch, + } + } + + /// spec: `update_fast_confirmation_variables(store)` + fn fcr_update_fast_confirmation_variables(&mut self) { + let current_slot = self.slot(); + let current_head = self.head().block_root; + + self.fcr_prev_slot_head = self.fcr_curr_slot_head; + self.fcr_curr_slot_head = current_head; + + // Last slot of epoch → snapshot greatest unrealized justified checkpoint + // Spec: is_start_slot_at_epoch(current_slot + 1) + let is_last_slot_of_epoch = misc::is_epoch_start::

(current_slot.saturating_add(1)); + if is_last_slot_of_epoch { + self.fcr_prev_gu_checkpoint = self.unrealized_justified_checkpoint; + } + + // First slot of epoch → rotate observed justified checkpoints + // Spec: is_start_slot_at_epoch(current_slot) + if misc::is_epoch_start::

(current_slot) { + self.fcr_prev_obs_justified = self.fcr_curr_obs_justified; + self.fcr_curr_obs_justified = self.fcr_prev_gu_checkpoint; + } + + // Refresh the per-slot FFG support cache. + // Block scope ensures checkpoint_states borrow is released before subsequent calls. + let balance_source_key = self.fcr_curr_obs_justified; + let ffg_inputs = if let Some(state) = self.checkpoint_states.get(&balance_source_key) { + let balances = Self::active_balances(state); + let total: Gwei = balances.iter().sum(); + Some((balances, total)) + } else { + None + }; + + if let Some((ref balances, total)) = ffg_inputs { + self.fcr_ffg_total_active_balance = total; + let ffg = self.fcr_build_ffg_data(balances, total); + self.fcr_honest_ffg_support = + fast_confirmation::compute_honest_ffg_support_for_current_target(&ffg); + } else { + self.fcr_ffg_total_active_balance = 0; + self.fcr_honest_ffg_support = 0; + } + } + + /// spec: `get_latest_confirmed(store)` — revert → restart → advance. + fn fcr_get_latest_confirmed(&self) -> H256 { + let current_epoch = self.current_epoch(); + let current_slot = self.slot(); + let head = self.head(); + let is_epoch_start = misc::is_epoch_start::

(current_slot); + + let confirmed_epoch = self + .chain_link(self.fcr_confirmed_root) + .map(|cl| misc::compute_epoch_at_slot::

(cl.slot())) + .unwrap_or(0); + + // confirmed_root is canonical iff ancestor of head at that slot equals confirmed_root + let confirmed_slot = self + .chain_link(self.fcr_confirmed_root) + .map(|cl| cl.slot()) + .unwrap_or(0); + let confirmed_canonical = self + .ancestor(head.block_root, confirmed_slot) + .is_some_and(|a| a == self.fcr_confirmed_root); + + let mut confirmed_root = + if confirmed_epoch + 1 < current_epoch || !confirmed_canonical { + self.finalized_checkpoint.root + } else if is_epoch_start { + // spec: is_confirmed_chain_safe — build chain with PREVIOUS balance source + let safe = (|| -> Option { + // confirmed_root must descend from curr_obs_justified + let obs_slot = self.chain_link(self.fcr_curr_obs_justified.root)?.slot(); + let obs_anc = self.ancestor(self.fcr_confirmed_root, obs_slot)?; + if obs_anc != self.fcr_curr_obs_justified.root { + return Some(false); + } + + // Determine start_root_exclusive for safety chain walk + let start_root_exclusive = + if self.fcr_curr_obs_justified.epoch + 1 >= current_epoch { + self.fcr_curr_obs_justified.root + } else { + let prev_epoch = current_epoch - 1; + let prev_epoch_start = + misc::compute_start_slot_at_epoch::

(prev_epoch); + let anc_root = + self.ancestor(self.fcr_confirmed_root, prev_epoch_start)?; + let anc_cl = self.chain_link(anc_root)?; + let anc_epoch = + misc::compute_epoch_at_slot::

(anc_cl.slot()); + if anc_epoch + 1 == current_epoch { + anc_cl.block.message().parent_root() + } else { + anc_root + } + }; + + let prev_state = + Arc::clone(self.checkpoint_states.get(&self.fcr_prev_obs_justified)?); + let prev_balances = Self::active_balances(&prev_state); + let prev_total: Gwei = prev_balances.iter().sum(); + + let chain = self.fcr_build_chain_info( + self.fcr_confirmed_root, + start_root_exclusive, + Some(&prev_state), + &prev_balances, + prev_total, + ); + Some(fast_confirmation::is_confirmed_chain_safe(&chain)) + })() + .unwrap_or(false); + + if safe { self.fcr_confirmed_root } else { self.finalized_checkpoint.root } + } else { + self.fcr_confirmed_root + }; + + let obs = self.fcr_curr_obs_justified; + if is_epoch_start + && obs.epoch + 1 == current_epoch + && obs == head.unrealized_justified_checkpoint + { + let confirmed_slot = self + .chain_link(confirmed_root) + .map(ChainLink::slot) + .unwrap_or(0); + let obs_slot = self.chain_link(obs.root).map(ChainLink::slot).unwrap_or(0); + if confirmed_slot < obs_slot { + confirmed_root = obs.root; + } + } + + let confirmed_epoch = self + .chain_link(confirmed_root) + .map(|cl| misc::compute_epoch_at_slot::

(cl.slot())) + .unwrap_or(0); + + if confirmed_epoch + 1 < current_epoch { + debug!(?confirmed_root, confirmed_epoch, current_epoch, "FCR: confirmed root too old, returning as-is"); + return confirmed_root; + } + + let curr_state = self + .checkpoint_states + .get(&self.fcr_curr_obs_justified) + .cloned(); + + let (chain, ffg) = if let Some(ref state) = curr_state { + let balances = Self::active_balances(state); + let total: Gwei = balances.iter().sum(); + let chain = self.fcr_build_chain_info( + head.block_root, + confirmed_root, + Some(state), + &balances, + total, + ); + let ffg = self.fcr_build_ffg_data(&balances, total); + (chain, ffg) + } else { + debug!(checkpoint = ?self.fcr_curr_obs_justified, "FCR: no balance source for curr_obs_justified, returning confirmed root"); + return confirmed_root; + }; + + let prev_head_voting_source_epoch = { + let cl = self.chain_link(self.fcr_prev_slot_head); + cl.map(|cl| { + fast_confirmation::get_voting_source_epoch( + misc::compute_epoch_at_slot::

(cl.slot()), + current_epoch, + cl.unrealized_justified_checkpoint.epoch, + self.justified_checkpoint.epoch, + ) + }) + .unwrap_or(0) + }; + let prev_head_unrealized_justified_epoch = self + .chain_link(self.fcr_prev_slot_head) + .map(|cl| cl.unrealized_justified_checkpoint.epoch) + .unwrap_or(0); + let head_unrealized_justified_epoch = head.unrealized_justified_checkpoint.epoch; + + fast_confirmation::find_latest_confirmed_descendant( + &chain, + confirmed_root, + confirmed_epoch, + current_epoch, + is_epoch_start, + &ffg, + prev_head_voting_source_epoch, + prev_head_unrealized_justified_epoch, + head_unrealized_justified_epoch, + ) + } + + /// spec: `on_fast_confirmation(store)` + fn fcr_on_fast_confirmation(&mut self) { + self.fcr_update_fast_confirmation_variables(); + let new_confirmed = self.fcr_get_latest_confirmed(); + self.fcr_confirmed_root = new_confirmed; + } } diff --git a/http_api/src/block_id.rs b/http_api/src/block_id.rs index 6d763fcb8..75f760375 100644 --- a/http_api/src/block_id.rs +++ b/http_api/src/block_id.rs @@ -26,6 +26,7 @@ pub fn block( .map(|checkpoint| checkpoint.block) .map(WithStatus::valid_and_finalized), BlockId::Finalized => Some(controller.last_finalized_block()), + BlockId::Safe => controller.block_by_root(controller.confirmed_root())?, BlockId::Slot(slot) => controller .block_by_slot(slot)? .map(|with_status| with_status.map(|block_with_root| block_with_root.block)), @@ -48,6 +49,7 @@ pub fn block_root( .map(|checkpoint| checkpoint.block.message().hash_tree_root()) .map(WithStatus::valid_and_finalized), BlockId::Finalized => Some(controller.last_finalized_block_root()), + BlockId::Safe => controller.check_block_root(controller.confirmed_root())?, BlockId::Slot(slot) => controller .block_by_slot(slot)? .map(|with_status| with_status.map(|with_status| with_status.root)), diff --git a/http_api_utils/src/block_id.rs b/http_api_utils/src/block_id.rs index 039fe104f..98436f824 100644 --- a/http_api_utils/src/block_id.rs +++ b/http_api_utils/src/block_id.rs @@ -8,6 +8,7 @@ pub enum BlockId { Head, Genesis, Finalized, + Safe, #[display("{0}")] Slot(Slot), #[display("{0:?}")] From 6d9a2fdf23c5df304bd98cb48074bb2b0ee4dac1 Mon Sep 17 00:00:00 2001 From: bomanaps Date: Sun, 12 Apr 2026 16:03:12 +0100 Subject: [PATCH 2/7] replace FCR feature flag with dedicated CLI flag --- features/src/lib.rs | 1 - fork_choice_control/src/extra_tests.rs | 46 ++-------------------- fork_choice_control/src/helpers.rs | 23 +++++++++++ fork_choice_control/src/queries.rs | 5 +-- fork_choice_control/src/spec_tests.rs | 1 + fork_choice_control/src/specialized.rs | 7 +++- fork_choice_store/src/fast_confirmation.rs | 2 +- fork_choice_store/src/store.rs | 12 +++--- fork_choice_store/src/store_config.rs | 2 + runtime/src/grandine_args.rs | 6 +++ runtime/src/grandine_config.rs | 1 + runtime/src/runtime.rs | 2 + 12 files changed, 54 insertions(+), 54 deletions(-) diff --git a/features/src/lib.rs b/features/src/lib.rs index b55dc8538..5691f4981 100644 --- a/features/src/lib.rs +++ b/features/src/lib.rs @@ -43,7 +43,6 @@ pub enum Feature { DumpBeaconBlocks, DumpBeaconStates, DumpBlobSidecars, - FastConfirmationRule, IgnoreAttestationsForUnknownBlocks, IgnoreFutureAttestations, InhibitApplicationRestart, diff --git a/fork_choice_control/src/extra_tests.rs b/fork_choice_control/src/extra_tests.rs index dea58a2d3..8962e1c67 100644 --- a/fork_choice_control/src/extra_tests.rs +++ b/fork_choice_control/src/extra_tests.rs @@ -18,7 +18,6 @@ use std::sync::Arc; use eth2_cache_utils::medalla; #[cfg(feature = "eth2-cache")] use eth2_libp2p::GossipId; -use features::Feature; use helper_functions::misc; #[cfg(feature = "eth2-cache")] use std_ext::ArcExt as _; @@ -2528,36 +2527,11 @@ fn reorganizing_due_to_invalidation_sends_notifications_if_common_ancestor_is_un // Fast Confirmation Rule (FCR) tests // ───────────────────────────────────────────────────────────────────────────── -/// RAII guard that serializes FCR tests and disables `FastConfirmationRule` on drop. -/// -/// `Feature::FastConfirmationRule` is a global flag. Without serialization, two FCR -/// tests running concurrently can race: one test's `drop` disables the flag while the -/// other test is still running, causing `confirmed_root()` to fall back to -/// `justified_checkpoint.root` and break assertions. -/// -/// Holding `FCR_LOCK` ensures at most one FCR test runs at a time. -static FCR_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -struct FcrGuard(std::sync::MutexGuard<'static, ()>); - -impl FcrGuard { - fn new() -> Self { - Self(FCR_LOCK.lock().expect("FCR test mutex is poisoned")) - } -} - -impl Drop for FcrGuard { - fn drop(&mut self) { - Feature::FastConfirmationRule.set_enabled(false); - } -} /// When FCR is disabled, `confirmed_root()` is an alias for `justified_checkpoint().root`. /// This verifies the fallback path stays correct after justification advances. #[test] fn fcr_disabled_confirmed_root_tracks_justified_checkpoint() { - Feature::FastConfirmationRule.set_enabled(false); - let mut context = Context::minimal(); let (_, state_0) = context.genesis(); @@ -2581,10 +2555,7 @@ fn fcr_disabled_confirmed_root_tracks_justified_checkpoint() { /// stay at the finalized checkpoint root — FCR must not advance without votes. #[test] fn fcr_enabled_stays_at_finalized_without_attestation_support() { - let _guard = FcrGuard::new(); - Feature::FastConfirmationRule.enable(); - - let mut context = Context::minimal(); + let mut context = Context::minimal_with_fcr(); let (_, state_0) = context.genesis(); let (block_1, _) = context.empty_block(&state_0, 1, H256::repeat_byte(1)); @@ -2616,10 +2587,7 @@ fn fcr_enabled_stays_at_finalized_without_attestation_support() { /// `justified_checkpoint.root` becomes the epoch-2 checkpoint root (block_1.root ≠ genesis). #[test] fn fcr_enabled_confirmed_is_more_conservative_than_justified() { - let _guard = FcrGuard::new(); - Feature::FastConfirmationRule.enable(); - - let mut context = Context::minimal(); + let mut context = Context::minimal_with_fcr(); let (_, state_0) = context.genesis(); let (block_0b, state_0b) = context.empty_block(&state_0, 7, H256::repeat_byte(0)); @@ -2682,10 +2650,7 @@ fn fcr_enabled_confirmed_is_more_conservative_than_justified() { /// restart condition (`2 + 1 == 3`) fires; `confirmed_root` jumps to block_1. #[test] fn fcr_advances_beyond_finalized_with_supermajority() { - let _guard = FcrGuard::new(); - Feature::FastConfirmationRule.enable(); - - let mut context = Context::minimal(); + let mut context = Context::minimal_with_fcr(); let (_, state_0) = context.genesis(); @@ -2747,10 +2712,7 @@ fn fcr_advances_beyond_finalized_with_supermajority() { /// confirmed_epoch (1) + 1 = 2 < current_epoch (4). #[test] fn fcr_reverts_to_finalized_when_confirmed_becomes_stale() { - let _guard = FcrGuard::new(); - Feature::FastConfirmationRule.enable(); - - let mut context = Context::minimal(); + let mut context = Context::minimal_with_fcr(); let (_, state_0) = context.genesis(); let (block_0b, state_0b) = diff --git a/fork_choice_control/src/helpers.rs b/fork_choice_control/src/helpers.rs index d6af5951e..c78c78544 100644 --- a/fork_choice_control/src/helpers.rs +++ b/fork_choice_control/src/helpers.rs @@ -74,6 +74,23 @@ impl Context

{ genesis_block, genesis_state, true, + false, + )) + } + + fn with_config_fcr(config: Config) -> Result { + let config = Arc::new(config); + let pubkey_cache = Arc::new(PubkeyCache::default()); + let (genesis_state, _) = factory::min_genesis_state(&config, &pubkey_cache)?; + let genesis_block = Arc::new(genesis::beacon_block(&genesis_state)); + + Ok(Self::new( + config, + pubkey_cache, + genesis_block, + genesis_state, + true, + true, )) } @@ -84,6 +101,7 @@ impl Context

{ anchor_block: Arc>, anchor_state: Arc>, optimistic_merge_block_validation: bool, + fast_confirmation_rule: bool, ) -> Self { let (service_tx, service_rx) = futures::channel::mpsc::unbounded(); @@ -104,6 +122,7 @@ impl Context

{ anchor_state, execution_engine.clone_arc(), p2p_tx, + fast_confirmation_rule, ); if phase.is_peerdas_activated() { @@ -764,6 +783,10 @@ impl Context { Self::with_config(Config::minimal()).expect("minimal configuration is valid") } + pub fn minimal_with_fcr() -> Self { + Self::with_config_fcr(Config::minimal()).expect("minimal configuration is valid") + } + pub fn bellatrix_minimal() -> Self { Self::with_config(Config::minimal().start_and_stay_in(Phase::Bellatrix)) .expect("minimal configuration modified to start in Bellatrix is valid") diff --git a/fork_choice_control/src/queries.rs b/fork_choice_control/src/queries.rs index a5c2a0a7f..776bfaa70 100644 --- a/fork_choice_control/src/queries.rs +++ b/fork_choice_control/src/queries.rs @@ -5,7 +5,6 @@ use anyhow::{Result, bail, ensure}; use arc_swap::Guard; use eth2_libp2p::GossipId; use execution_engine::ExecutionEngine; -use features::Feature; use fork_choice_store::{ AggregateAndProofOrigin, AttestationItem, BlobSidecarAction, BlobSidecarOrigin, ChainLink, DataColumnSidecarAction, DataColumnSidecarOrigin, StateCacheProcessor, Store, @@ -239,7 +238,7 @@ where }) .collect(); - let extra_data = Feature::FastConfirmationRule.is_enabled().then(|| FcrExtraData { + let extra_data = store.store_config().fast_confirmation_rule.then(|| FcrExtraData { confirmed_root: store.confirmed_root(), current_epoch_observed_justified_checkpoint: store.fcr_curr_obs_justified(), previous_epoch_greatest_unrealized_checkpoint: store.fcr_prev_gu_checkpoint(), @@ -1061,7 +1060,7 @@ pub struct ForkChoiceContext { } /// FCR internal state exposed on `GET /eth/v1/debug/fork_choice`. -/// Only serialized when `Feature::FastConfirmationRule` is enabled. +/// Only serialized when `store_config.fast_confirmation_rule` is enabled. #[derive(Serialize)] struct FcrExtraData { confirmed_root: H256, diff --git a/fork_choice_control/src/spec_tests.rs b/fork_choice_control/src/spec_tests.rs index 0cb26c156..51cc3bb3b 100644 --- a/fork_choice_control/src/spec_tests.rs +++ b/fork_choice_control/src/spec_tests.rs @@ -185,6 +185,7 @@ async fn run_case(config: &Arc, case: Case<'_>) { anchor_block, anchor_state, false, + false, ); let mut last_payload_status: Option = None; diff --git a/fork_choice_control/src/specialized.rs b/fork_choice_control/src/specialized.rs index 19e1344d6..b1d97d3b5 100644 --- a/fork_choice_control/src/specialized.rs +++ b/fork_choice_control/src/specialized.rs @@ -203,6 +203,7 @@ impl TestController

{ anchor_state, Arc::new(Mutex::new(MockExecutionEngine::new(true, false, None))), futures::sink::drain(), + false, ) } @@ -213,8 +214,12 @@ impl TestController

{ anchor_state: Arc>, execution_engine: TestExecutionEngine

, p2p_tx: impl UnboundedSink>, + fast_confirmation_rule: bool, ) -> (Arc, MutatorHandle) { - let store_config = StoreConfig::aggressive(&chain_config); + let store_config = StoreConfig { + fast_confirmation_rule, + ..StoreConfig::aggressive(&chain_config) + }; Self::new_internal( chain_config, diff --git a/fork_choice_store/src/fast_confirmation.rs b/fork_choice_store/src/fast_confirmation.rs index b01c52d2d..1d105f20f 100644 --- a/fork_choice_store/src/fast_confirmation.rs +++ b/fork_choice_store/src/fast_confirmation.rs @@ -1,6 +1,6 @@ /// Fast Confirmation Rule (FCR) constants, types, and free functions. /// -/// See the [FCR specification](https://github.com/ethereum/consensus-specs/blob/main/specs/phase0/fast-confirmation.md). +/// See the [FCR specification](https://github.com/ethereum/consensus-specs/pull/4747). use arithmetic::NonZeroExt as _; use helper_functions::misc; diff --git a/fork_choice_store/src/store.rs b/fork_choice_store/src/store.rs index 7b6fda02d..4c48fb989 100644 --- a/fork_choice_store/src/store.rs +++ b/fork_choice_store/src/store.rs @@ -270,7 +270,7 @@ pub struct Store> { requested_blobs_from_el: HashMap, current_slot_blocks_in_processing: Arc, // === Fast Confirmation Rule (FCR) fields === - // Only meaningful when `Feature::FastConfirmationRule` is enabled. + // Only meaningful when `store_config.fast_confirmation_rule` is enabled. // spec: `confirmed_root` fcr_confirmed_root: H256, // spec: `previous_epoch_observed_justified_checkpoint` @@ -1077,7 +1077,7 @@ impl> Store { /// [`get_safe_execution_payload_hash`](https://github.com/ethereum/consensus-specs/blob/v1.3.0/fork_choice/safe-block.md#get_safe_execution_payload_hash) #[must_use] pub fn safe_execution_payload_hash(&self) -> ExecutionBlockHash { - if Feature::FastConfirmationRule.is_enabled() && self.fcr_confirmed_root != H256::zero() { + if self.store_config.fast_confirmation_rule && self.fcr_confirmed_root != H256::zero() { if let Some(hash) = self .chain_link(self.fcr_confirmed_root) .and_then(ChainLink::execution_block_hash) @@ -1092,11 +1092,11 @@ impl> Store { /// Returns the current confirmed block root. /// - /// When `Feature::FastConfirmationRule` is enabled, this is the FCR-confirmed root. + /// When `store_config.fast_confirmation_rule` is enabled, this is the FCR-confirmed root. /// Otherwise falls back to the justified checkpoint root. #[must_use] pub fn confirmed_root(&self) -> H256 { - if Feature::FastConfirmationRule.is_enabled() { + if self.store_config.fast_confirmation_rule { self.fcr_confirmed_root } else { self.justified_checkpoint.root @@ -2913,7 +2913,7 @@ impl> Store { } // FCR: run once per slot after attestations are applied and head is updated. - if Feature::FastConfirmationRule.is_enabled() { + if self.store_config.fast_confirmation_rule { self.fcr_on_fast_confirmation(); } @@ -3064,7 +3064,7 @@ impl> Store { // FCR: advance confirmed when head changes. // Note: fcr_update_fast_confirmation_variables is NOT called here — it runs once per // slot in apply_tick. This path only re-runs get_latest_confirmed when the head moves. - if Feature::FastConfirmationRule.is_enabled() { + if self.store_config.fast_confirmation_rule { let new_head_root = self.head().block_root; if new_head_root != old_head.block_root { let new_confirmed = self.fcr_get_latest_confirmed(); diff --git a/fork_choice_store/src/store_config.rs b/fork_choice_store/src/store_config.rs index 0f4549f96..6959e2c04 100644 --- a/fork_choice_store/src/store_config.rs +++ b/fork_choice_store/src/store_config.rs @@ -21,6 +21,8 @@ pub struct StoreConfig { pub kzg_backend: KzgBackend, #[derivative(Default(value = "false"))] pub sync_without_reconstruction: bool, + #[derivative(Default(value = "false"))] + pub fast_confirmation_rule: bool, } impl StoreConfig { diff --git a/runtime/src/grandine_args.rs b/runtime/src/grandine_args.rs index 7cf3bc670..cf9e0fad5 100644 --- a/runtime/src/grandine_args.rs +++ b/runtime/src/grandine_args.rs @@ -125,6 +125,10 @@ pub struct GrandineArgs { #[clap(long, value_delimiter = ',')] features: Vec, + /// Enable the Fast Confirmation Rule for single-slot block confirmation + #[clap(long)] + fast_confirmation_rule: bool, + #[clap(subcommand)] command: Option, } @@ -1014,6 +1018,7 @@ impl GrandineArgs { graffiti, disable_blockprint_graffiti, mut features, + fast_confirmation_rule, command, .. } = self; @@ -1508,6 +1513,7 @@ impl GrandineArgs { report_validator_performance, backfill_custody_groups: !no_custody_groups_backfill, sync_without_reconstruction, + fast_confirmation_rule, custody_mode, disable_wait_for_late_blocks, }) diff --git a/runtime/src/grandine_config.rs b/runtime/src/grandine_config.rs index f2ca1bd50..6c658ce2b 100644 --- a/runtime/src/grandine_config.rs +++ b/runtime/src/grandine_config.rs @@ -78,6 +78,7 @@ pub struct GrandineConfig { pub report_validator_performance: bool, pub backfill_custody_groups: bool, pub sync_without_reconstruction: bool, + pub fast_confirmation_rule: bool, pub custody_mode: CustodyMode, pub disable_wait_for_late_blocks: bool, } diff --git a/runtime/src/runtime.rs b/runtime/src/runtime.rs index 72172e927..05e6657e8 100644 --- a/runtime/src/runtime.rs +++ b/runtime/src/runtime.rs @@ -1295,6 +1295,7 @@ pub fn run(parsed_args: GrandineArgs) -> Result<()> { report_validator_performance, backfill_custody_groups, sync_without_reconstruction, + fast_confirmation_rule, custody_mode, disable_wait_for_late_blocks, .. @@ -1345,6 +1346,7 @@ pub fn run(parsed_args: GrandineArgs) -> Result<()> { unfinalized_states_in_memory, kzg_backend, sync_without_reconstruction, + fast_confirmation_rule, }; let eth1_auth = Arc::new(Auth::new(auth_options)?); From 88e3fb458263be0bf73b14d8f71d099bf14dcad0 Mon Sep 17 00:00:00 2001 From: bomanaps Date: Mon, 13 Apr 2026 19:12:56 +0100 Subject: [PATCH 3/7] align FCR implementation with latest spec updates --- fork_choice_control/src/extra_tests.rs | 62 +++---- fork_choice_control/src/queries.rs | 17 +- fork_choice_store/src/fast_confirmation.rs | 24 ++- fork_choice_store/src/store.rs | 183 ++++++++++++--------- 4 files changed, 155 insertions(+), 131 deletions(-) diff --git a/fork_choice_control/src/extra_tests.rs b/fork_choice_control/src/extra_tests.rs index 8962e1c67..bc29a194f 100644 --- a/fork_choice_control/src/extra_tests.rs +++ b/fork_choice_control/src/extra_tests.rs @@ -2527,7 +2527,6 @@ fn reorganizing_due_to_invalidation_sends_notifications_if_common_ancestor_is_un // Fast Confirmation Rule (FCR) tests // ───────────────────────────────────────────────────────────────────────────── - /// When FCR is disabled, `confirmed_root()` is an alias for `justified_checkpoint().root`. /// This verifies the fallback path stays correct after justification advances. #[test] @@ -2536,8 +2535,7 @@ fn fcr_disabled_confirmed_root_tracks_justified_checkpoint() { let (_, state_0) = context.genesis(); let (block_1, state_1) = context.empty_block(&state_0, start_of_epoch(1), H256::default()); - let (block_2, _) = - context.block_justifying_current_epoch(&state_1, 1, H256::repeat_byte(1)); + let (block_2, _) = context.block_justifying_current_epoch(&state_1, 1, H256::repeat_byte(1)); // Advance to epoch 3 so block_2's justification is fully committed. context.on_slot(start_of_epoch(3)); @@ -2595,12 +2593,8 @@ fn fcr_enabled_confirmed_is_more_conservative_than_justified() { let (block_1, state_1) = context.empty_block(&state_0b, 15, H256::repeat_byte(1)); // block_2 at slot 22 with 6 epoch-2 attestation slots (75 % ≥ ⅔) — justifies epoch 2. // block_2.unrealized_justified_checkpoint = { epoch: 2, root: block_1.root } - let (block_2, _) = context.block_with_attestations_for_slots( - &state_1, - 22, - 16..22, - H256::repeat_byte(2), - ); + let (block_2, _) = + context.block_with_attestations_for_slots(&state_1, 22, 16..22, H256::repeat_byte(2)); // Advance to epoch 3 FIRST (obs = genesis), then apply blocks. // The epoch-3 tick sets fcr_curr_obs_justified from the slot-23 GU snapshot = genesis. @@ -2643,11 +2637,15 @@ fn fcr_enabled_confirmed_is_more_conservative_than_justified() { /// /// Sequence: /// 1. `on_slot(22)` — blocks applied here so that the slot-23 GU snapshot sees epoch-2 justification. -/// 2. Apply block_0b (slot 7), block_1 (slot 15), block_2 (slot 22, 6 epoch-2 attestation slots). +/// 2. Apply block_0b (slot 7), block_1 (slot 16), block_2 (slot 22, 6 epoch-2 attestation slots). /// block_2's `unrealized_justified_checkpoint = { epoch: 2, root: block_1.root }`. /// 3. `on_slot(23)` — last of epoch 2; GU snapshot captures `{ epoch: 2, root: block_1.root }`. /// 4. `on_slot(24)` — epoch-3 start; rotation sets `fcr_curr_obs = { epoch: 2, root: block_1.root }`; -/// restart condition (`2 + 1 == 3`) fires; `confirmed_root` jumps to block_1. +/// restart condition (`compute_epoch_at_slot(16) + 1 == 3`) fires; `confirmed_root` jumps to block_1. +/// +/// Per spec PR #34, the restart condition now uses the epoch of the block that the justified +/// checkpoint points to (not the checkpoint's epoch field). block_1 must therefore be at a slot +/// in epoch 2 so that `compute_epoch_at_slot(block_1.slot) + 1 == current_epoch (3)`. #[test] fn fcr_advances_beyond_finalized_with_supermajority() { let mut context = Context::minimal_with_fcr(); @@ -2656,25 +2654,19 @@ fn fcr_advances_beyond_finalized_with_supermajority() { // block_0b at slot 7: a non-genesis block so that subsequent checkpoint roots differ // from genesis. - let (block_0b, state_0b) = - context.empty_block(&state_0, 7, H256::repeat_byte(1)); + let (block_0b, state_0b) = context.empty_block(&state_0, 7, H256::repeat_byte(1)); - // block_1 at slot 15: becomes the epoch-2 checkpoint root. - // state.block_roots[16] is set by process_slot to block_1.root BEFORE any slot-16 block - // is processed, so the epoch-2 checkpoint root is always block_1.root. - let (block_1, state_1) = - context.empty_block(&state_0b, 15, H256::repeat_byte(2)); + // block_1 at slot 16: the first block of epoch 2 and thus the epoch-2 checkpoint root. + // Per spec, the restart condition checks compute_epoch_at_slot(block_1.slot) = 2, + // which satisfies `2 + 1 == current_epoch (3)`. + let (block_1, state_1) = context.empty_block(&state_0b, 16, H256::repeat_byte(2)); // block_2 at slot 22 with attestations for slots 16..22 (6 of 8 epoch-2 slots = 75 % ≥ ⅔). // process_justification_and_finalization at epoch 2 (current_epoch > 1, so no early return) // sees 75% epoch-2 participation and justifies epoch 2. // → block_2.unrealized_justified_checkpoint = { epoch: 2, root: block_1.root } - let (block_2, _) = context.block_with_attestations_for_slots( - &state_1, - 22, - 16..22, - H256::repeat_byte(3), - ); + let (block_2, _) = + context.block_with_attestations_for_slots(&state_1, 22, 16..22, H256::repeat_byte(3)); // Advance to slot 22 and apply all three blocks so that store.unrealized_justified reflects // epoch-2 justification before the slot-23 GU snapshot fires. @@ -2707,24 +2699,18 @@ fn fcr_advances_beyond_finalized_with_supermajority() { /// more than one epoch old (`confirmed_epoch + 1 < current_epoch`). /// /// Uses the same three-block setup as `fcr_advances_beyond_finalized_with_supermajority` -/// to establish FCR advancement to block_1 (epoch-1 slot, epoch-2 checkpoint) at epoch 3, +/// to establish FCR advancement to block_1 (epoch-2 block, epoch-2 checkpoint) at epoch 3, /// then verifies that advancing to epoch 4 triggers the age-revert condition: -/// confirmed_epoch (1) + 1 = 2 < current_epoch (4). +/// confirmed_epoch (2) + 1 = 3 < current_epoch (4). #[test] fn fcr_reverts_to_finalized_when_confirmed_becomes_stale() { let mut context = Context::minimal_with_fcr(); let (_, state_0) = context.genesis(); - let (block_0b, state_0b) = - context.empty_block(&state_0, 7, H256::repeat_byte(1)); - let (block_1, state_1) = - context.empty_block(&state_0b, 15, H256::repeat_byte(2)); - let (block_2, _) = context.block_with_attestations_for_slots( - &state_1, - 22, - 16..22, - H256::repeat_byte(3), - ); + let (block_0b, state_0b) = context.empty_block(&state_0, 7, H256::repeat_byte(1)); + let (block_1, state_1) = context.empty_block(&state_0b, 16, H256::repeat_byte(2)); + let (block_2, _) = + context.block_with_attestations_for_slots(&state_1, 22, 16..22, H256::repeat_byte(3)); // Replicate the advancement setup from fcr_advances_beyond_finalized_with_supermajority. context.on_slot(22); @@ -2742,8 +2728,8 @@ fn fcr_reverts_to_finalized_when_confirmed_becomes_stale() { "FCR should have advanced to block_1 at epoch-3 boundary (precondition for revert test)", ); - // Advance to epoch 4. The confirmed block (block_1) is at slot 15, epoch 1. - // The age-revert condition fires: confirmed_epoch (1) + 1 = 2 < current_epoch (4). + // Advance to epoch 4. The confirmed block (block_1) is at slot 16, epoch 2. + // The age-revert condition fires: confirmed_epoch (2) + 1 = 3 < current_epoch (4). context.on_slot(start_of_epoch(4)); let confirmed_at_epoch_4 = context.confirmed_root(); diff --git a/fork_choice_control/src/queries.rs b/fork_choice_control/src/queries.rs index 776bfaa70..4afdfa6fe 100644 --- a/fork_choice_control/src/queries.rs +++ b/fork_choice_control/src/queries.rs @@ -238,13 +238,16 @@ where }) .collect(); - let extra_data = store.store_config().fast_confirmation_rule.then(|| FcrExtraData { - confirmed_root: store.confirmed_root(), - current_epoch_observed_justified_checkpoint: store.fcr_curr_obs_justified(), - previous_epoch_greatest_unrealized_checkpoint: store.fcr_prev_gu_checkpoint(), - previous_slot_head: store.fcr_prev_slot_head(), - current_slot_head: store.fcr_curr_slot_head(), - }); + let extra_data = store + .store_config() + .fast_confirmation_rule + .then(|| FcrExtraData { + confirmed_root: store.confirmed_root(), + current_epoch_observed_justified_checkpoint: store.fcr_curr_obs_justified(), + previous_epoch_greatest_unrealized_checkpoint: store.fcr_prev_gu_checkpoint(), + previous_slot_head: store.fcr_prev_slot_head(), + current_slot_head: store.fcr_curr_slot_head(), + }); ForkChoiceContext { justified_checkpoint: store.justified_checkpoint(), diff --git a/fork_choice_store/src/fast_confirmation.rs b/fork_choice_store/src/fast_confirmation.rs index 1d105f20f..b1cc5ea63 100644 --- a/fork_choice_store/src/fast_confirmation.rs +++ b/fork_choice_store/src/fast_confirmation.rs @@ -1,7 +1,6 @@ /// Fast Confirmation Rule (FCR) constants, types, and free functions. /// /// See the [FCR specification](https://github.com/ethereum/consensus-specs/pull/4747). - use arithmetic::NonZeroExt as _; use helper_functions::misc; use typenum::Unsigned as _; @@ -53,6 +52,9 @@ pub struct ChainInfo { /// spec: `compute_empty_slot_support_discount` — pre-computed because it needs /// `get_block_support_between_slots` for the parent in empty slots. pub support_discount: Gwei, + /// `false` if this block's payload status is not VALID (i.e. optimistic). + /// `is_one_confirmed` MUST return `false` for non-VALID blocks per the optimistic sync spec. + pub is_valid: bool, } /// FFG-related state built by `Store::fcr_build_ffg_data`. @@ -107,8 +109,7 @@ pub fn estimate_committee_weight_between_slots( // end_full_epoch = epoch(end_slot + 1) // covered if start_full_epoch < end_full_epoch let spe = P::SlotsPerEpoch::U64; - let start_full_epoch = - misc::compute_epoch_at_slot::

(start_slot.saturating_add(spe - 1)); + let start_full_epoch = misc::compute_epoch_at_slot::

(start_slot.saturating_add(spe - 1)); let end_full_epoch = misc::compute_epoch_at_slot::

(end_slot.saturating_add(1)); if start_full_epoch < end_full_epoch { return total_active_balance; @@ -130,14 +131,13 @@ pub fn estimate_committee_weight_between_slots( let end_epoch_weight = committee_weight * num_slots_in_end_epoch; // pro-rated: start_epoch_weight * remaining_in_end / SLOTS_PER_EPOCH - let start_epoch_weight_pro_rated = - start_epoch_weight / spe * remaining_slots_in_end_epoch; + let start_epoch_weight_pro_rated = start_epoch_weight / spe * remaining_slots_in_end_epoch; let raw_estimate = start_epoch_weight_pro_rated + end_epoch_weight; // adjust_committee_weight_estimate_to_ensure_safety: - // estimate * (1000 + ADJUSTMENT_FACTOR) / 1000 - raw_estimate / 1000 * (1000 + COMMITTEE_WEIGHT_ESTIMATION_ADJUSTMENT_FACTOR) + // ceil(estimate / 1000) * (1000 + ADJUSTMENT_FACTOR) + (raw_estimate + 999) / 1000 * (1000 + COMMITTEE_WEIGHT_ESTIMATION_ADJUSTMENT_FACTOR) } } @@ -195,7 +195,8 @@ pub fn compute_empty_slot_support_discount(info: &ChainInfo) -> Gwei { /// /// Returns `(maximum_support + proposer_score + 2*adversarial_weight - support_discount) / 2`. pub fn compute_safety_threshold(info: &ChainInfo) -> Gwei { - let adversarial_weight = compute_adversarial_weight(info.adv_committee_weight, info.adversarial); + let adversarial_weight = + compute_adversarial_weight(info.adv_committee_weight, info.adversarial); let support_discount = compute_empty_slot_support_discount(info); let lhs = info @@ -215,7 +216,12 @@ pub fn compute_safety_threshold(info: &ChainInfo) -> Gwei { // ============================================================ /// spec: `is_one_confirmed(store, balance_source, block_root)` +/// +/// Per the optimistic sync spec, MUST return `false` if the block's payload status is not VALID. pub fn is_one_confirmed(info: &ChainInfo) -> bool { + if !info.is_valid { + return false; + } info.support > compute_safety_threshold(info) } @@ -284,7 +290,7 @@ pub fn will_no_conflicting_checkpoint_be_justified(ffg: &FcrFfgData) -> bool { if ffg.total_active_balance == 0 { return false; } - 3 * ffg.honest_ffg_support >= ffg.total_active_balance + 3 * ffg.honest_ffg_support > ffg.total_active_balance } // ============================================================ diff --git a/fork_choice_store/src/store.rs b/fork_choice_store/src/store.rs index 4c48fb989..5cc17047c 100644 --- a/fork_choice_store/src/store.rs +++ b/fork_choice_store/src/store.rs @@ -4794,12 +4794,7 @@ impl> Store { /// /// Sums the active balances of validators whose latest message descends through /// `root` at `slot`. - fn fcr_get_attestation_score( - &self, - root: H256, - slot: Slot, - active_balances: &[Gwei], - ) -> Gwei { + fn fcr_get_attestation_score(&self, root: H256, slot: Slot, active_balances: &[Gwei]) -> Gwei { self.latest_messages .iter() .enumerate() @@ -4873,12 +4868,11 @@ impl> Store { }) .sum(); - let empty_committee_weight = - fast_confirmation::estimate_committee_weight_between_slots::

( - total_active_balance, - empty_start, - empty_end, - ); + let empty_committee_weight = fast_confirmation::estimate_committee_weight_between_slots::

( + total_active_balance, + empty_start, + empty_end, + ); let empty_equivocation: Gwei = if self.equivocating_indices.is_empty() { 0 } else { @@ -4920,12 +4914,18 @@ impl> Store { let cl = match self.chain_link(current) { Some(cl) => cl, None => { - debug!(?current, "FCR: ancestor not found in store while building chain"); + debug!( + ?current, + "FCR: ancestor not found in store while building chain" + ); return vec![]; } }; if cl.slot() <= terminal_slot { - debug!(slot = cl.slot(), terminal_slot, "FCR: chain reached terminal slot"); + debug!( + slot = cl.slot(), + terminal_slot, "FCR: chain reached terminal slot" + ); return vec![]; } roots.push(current); @@ -4940,7 +4940,11 @@ impl> Store { let current_slot = self.slot(); let current_epoch = self.current_epoch(); let proposer_score_boost = self.chain_config.proposer_score_boost; - let end_slot = if current_slot > 0 { current_slot - 1 } else { 0 }; + let end_slot = if current_slot > 0 { + current_slot - 1 + } else { + 0 + }; roots .into_iter() @@ -4951,7 +4955,11 @@ impl> Store { let parent_slot = match self.chain_link(parent_root) { Some(cl) => cl.slot(), None => { - debug!(?root, ?parent_root, "FCR: parent not in store mid-chain, skipping block"); + debug!( + ?root, + ?parent_root, + "FCR: parent not in store mid-chain, skipping block" + ); return None; } }; @@ -5048,6 +5056,8 @@ impl> Store { adv_committee_weight, proposer_score, support_discount, + // Per the optimistic sync spec, a block must have VALID payload to be confirmable. + is_valid: !cl.is_optimistic(), }) }) .collect() @@ -5069,10 +5079,12 @@ impl> Store { let head = self.head(); // current_target for the spec shortcut in will_no_conflicting_checkpoint_be_justified - let current_target = self.ancestor(head.block_root, epoch_start).map(|root| Checkpoint { - epoch: current_epoch, - root, - }); + let current_target = self + .ancestor(head.block_root, epoch_start) + .map(|root| Checkpoint { + epoch: current_epoch, + root, + }); // spec: get_current_target_score — stays on Store (needs latest_messages + ancestor) let current_target_root = current_target.map(|cp| cp.root); @@ -5084,12 +5096,22 @@ impl> Store { .filter_map(|(i, msg_opt)| { let msg = msg_opt.as_ref()?; let balance = *active_balances.get(i)?; - if balance == 0 { return None; } - if self.equivocating_indices.contains(&(i as ValidatorIndex)) { return None; } - if msg.epoch != current_epoch { return None; } - if !self.contains_block(msg.beacon_block_root) { return None; } + if balance == 0 { + return None; + } + if self.equivocating_indices.contains(&(i as ValidatorIndex)) { + return None; + } + if msg.epoch != current_epoch { + return None; + } + if !self.contains_block(msg.beacon_block_root) { + return None; + } let vote_target = self.ancestor(msg.beacon_block_root, epoch_start)?; - if vote_target != target_root { return None; } + if vote_target != target_root { + return None; + } Some(balance) }) .sum() @@ -5220,70 +5242,74 @@ impl> Store { .ancestor(head.block_root, confirmed_slot) .is_some_and(|a| a == self.fcr_confirmed_root); - let mut confirmed_root = - if confirmed_epoch + 1 < current_epoch || !confirmed_canonical { - self.finalized_checkpoint.root - } else if is_epoch_start { - // spec: is_confirmed_chain_safe — build chain with PREVIOUS balance source - let safe = (|| -> Option { - // confirmed_root must descend from curr_obs_justified - let obs_slot = self.chain_link(self.fcr_curr_obs_justified.root)?.slot(); - let obs_anc = self.ancestor(self.fcr_confirmed_root, obs_slot)?; - if obs_anc != self.fcr_curr_obs_justified.root { - return Some(false); + let mut confirmed_root = if confirmed_epoch + 1 < current_epoch || !confirmed_canonical { + self.finalized_checkpoint.root + } else if is_epoch_start { + // spec: is_confirmed_chain_safe — build chain with PREVIOUS balance source + let safe = (|| -> Option { + // confirmed_root must descend from curr_obs_justified + let obs_slot = self.chain_link(self.fcr_curr_obs_justified.root)?.slot(); + let obs_anc = self.ancestor(self.fcr_confirmed_root, obs_slot)?; + if obs_anc != self.fcr_curr_obs_justified.root { + return Some(false); + } + + // Determine start_root_exclusive for safety chain walk + let start_root_exclusive = if self.fcr_curr_obs_justified.epoch + 1 >= current_epoch + { + self.fcr_curr_obs_justified.root + } else { + let prev_epoch = current_epoch - 1; + let prev_epoch_start = misc::compute_start_slot_at_epoch::

(prev_epoch); + let anc_root = self.ancestor(self.fcr_confirmed_root, prev_epoch_start)?; + let anc_cl = self.chain_link(anc_root)?; + let anc_epoch = misc::compute_epoch_at_slot::

(anc_cl.slot()); + if anc_epoch + 1 == current_epoch { + anc_cl.block.message().parent_root() + } else { + anc_root } + }; - // Determine start_root_exclusive for safety chain walk - let start_root_exclusive = - if self.fcr_curr_obs_justified.epoch + 1 >= current_epoch { - self.fcr_curr_obs_justified.root - } else { - let prev_epoch = current_epoch - 1; - let prev_epoch_start = - misc::compute_start_slot_at_epoch::

(prev_epoch); - let anc_root = - self.ancestor(self.fcr_confirmed_root, prev_epoch_start)?; - let anc_cl = self.chain_link(anc_root)?; - let anc_epoch = - misc::compute_epoch_at_slot::

(anc_cl.slot()); - if anc_epoch + 1 == current_epoch { - anc_cl.block.message().parent_root() - } else { - anc_root - } - }; - - let prev_state = - Arc::clone(self.checkpoint_states.get(&self.fcr_prev_obs_justified)?); - let prev_balances = Self::active_balances(&prev_state); - let prev_total: Gwei = prev_balances.iter().sum(); - - let chain = self.fcr_build_chain_info( - self.fcr_confirmed_root, - start_root_exclusive, - Some(&prev_state), - &prev_balances, - prev_total, - ); - Some(fast_confirmation::is_confirmed_chain_safe(&chain)) - })() - .unwrap_or(false); + let prev_state = + Arc::clone(self.checkpoint_states.get(&self.fcr_prev_obs_justified)?); + let prev_balances = Self::active_balances(&prev_state); + let prev_total: Gwei = prev_balances.iter().sum(); + + let chain = self.fcr_build_chain_info( + self.fcr_confirmed_root, + start_root_exclusive, + Some(&prev_state), + &prev_balances, + prev_total, + ); + Some(fast_confirmation::is_confirmed_chain_safe(&chain)) + })() + .unwrap_or(false); - if safe { self.fcr_confirmed_root } else { self.finalized_checkpoint.root } - } else { + if safe { self.fcr_confirmed_root - }; + } else { + self.finalized_checkpoint.root + } + } else { + self.fcr_confirmed_root + }; let obs = self.fcr_curr_obs_justified; + // spec PR: use the epoch of the block obs.root points to, not checkpoint.epoch, + // because a checkpoint may have epoch N while its block is from epoch N-1 + // (when the epoch-start slot was skipped). + let obs_slot = self.chain_link(obs.root).map(ChainLink::slot).unwrap_or(0); + let obs_block_epoch = misc::compute_epoch_at_slot::

(obs_slot); if is_epoch_start - && obs.epoch + 1 == current_epoch + && obs_block_epoch + 1 == current_epoch && obs == head.unrealized_justified_checkpoint { let confirmed_slot = self .chain_link(confirmed_root) .map(ChainLink::slot) .unwrap_or(0); - let obs_slot = self.chain_link(obs.root).map(ChainLink::slot).unwrap_or(0); if confirmed_slot < obs_slot { confirmed_root = obs.root; } @@ -5295,7 +5321,10 @@ impl> Store { .unwrap_or(0); if confirmed_epoch + 1 < current_epoch { - debug!(?confirmed_root, confirmed_epoch, current_epoch, "FCR: confirmed root too old, returning as-is"); + debug!( + ?confirmed_root, + confirmed_epoch, current_epoch, "FCR: confirmed root too old, returning as-is" + ); return confirmed_root; } From dcf5b0a7e8e622aa818b3f8ddf557ee5de9a4c09 Mon Sep 17 00:00:00 2001 From: bomanaps Date: Sat, 25 Apr 2026 11:17:39 +0100 Subject: [PATCH 4/7] address all 11 review comment --- factory/src/lib.rs | 2 +- fork_choice_control/src/controller.rs | 18 +- fork_choice_control/src/extra_tests.rs | 39 +- fork_choice_control/src/helpers.rs | 51 ++- fork_choice_control/src/mutator.rs | 54 ++- fork_choice_control/src/queries.rs | 102 ++++- fork_choice_control/src/spec_tests.rs | 102 ++++- fork_choice_store/src/fast_confirmation.rs | 433 ------------------ .../src/fast_confirmation/mod.rs | 24 + .../src/fast_confirmation/rules.rs | 328 +++++++++++++ .../src/fast_confirmation/store.rs | 397 ++++++++++++++++ .../src/fast_confirmation/types.rs | 70 +++ fork_choice_store/src/lib.rs | 3 +- fork_choice_store/src/store.rs | 417 +++-------------- grandine-snapshot-tests | 2 +- http_api/src/block_id.rs | 16 +- runtime/src/grandine_args.rs | 10 +- scripts/download_spec_tests.sh | 2 +- types/src/config.rs | 7 + 19 files changed, 1239 insertions(+), 838 deletions(-) delete mode 100644 fork_choice_store/src/fast_confirmation.rs create mode 100644 fork_choice_store/src/fast_confirmation/mod.rs create mode 100644 fork_choice_store/src/fast_confirmation/rules.rs create mode 100644 fork_choice_store/src/fast_confirmation/store.rs create mode 100644 fork_choice_store/src/fast_confirmation/types.rs diff --git a/factory/src/lib.rs b/factory/src/lib.rs index c869d53ee..2558f22da 100644 --- a/factory/src/lib.rs +++ b/factory/src/lib.rs @@ -178,7 +178,7 @@ pub fn block_with_attestations_for_slots( pubkey_cache: &PubkeyCache, pre_state: Arc>, block_slot: Slot, - attestation_slots: core::ops::Range, + attestation_slots: Range, graffiti: H256, ) -> Result> { let advanced_state = advance_state(config, pubkey_cache, pre_state, block_slot)?; diff --git a/fork_choice_control/src/controller.rs b/fork_choice_control/src/controller.rs index 6884dd9d3..ddecebe57 100644 --- a/fork_choice_control/src/controller.rs +++ b/fork_choice_control/src/controller.rs @@ -24,7 +24,7 @@ use execution_engine::{ExecutionEngine, PayloadStatusV1}; use fork_choice_store::{ AggregateAndProofOrigin, AttestationItem, AttestationOrigin, AttesterSlashingOrigin, BlobSidecarOrigin, BlockOrigin, DataColumnSidecarOrigin, ExecutionPayloadBidOrigin, - StateCacheProcessor, Store, StoreConfig, + FastConfirmationStore, StateCacheProcessor, Store, StoreConfig, }; use futures::channel::{mpsc::Sender as MultiSender, oneshot::Sender as OneshotSender}; use genesis::AnchorCheckpointProvider; @@ -80,6 +80,8 @@ use crate::{ pub struct Controller { // The latest consistent snapshot of the store. store_snapshot: Arc>>>, + // The latest consistent snapshot of the Fast Confirmation Rule store (None when FCR is disabled). + fcr_snapshot: Arc>>>, block_processor: Arc>, execution_engine: E, pubkey_cache: Arc, @@ -148,6 +150,14 @@ where store.apply_tick(tick)?; + // Instantiate the Fast Confirmation Rule store iff the feature is enabled. Per spec + // `get_fast_confirmation_store`, it anchors at `store.finalized_checkpoint`. + let fcr_store = store + .store_config() + .fast_confirmation_rule + .then(|| FastConfirmationStore::new(&store)); + let fcr_snapshot = Arc::new(ArcSwap::from_pointee(fcr_store)); + let state_cache = store.state_cache(); let store_snapshot = Arc::new(ArcSwap::from_pointee(store)); let thread_pool = ThreadPool::new()?; @@ -165,6 +175,7 @@ where let mut mutator = Mutator::new( pubkey_cache.clone_arc(), store_snapshot.clone_arc(), + fcr_snapshot.clone_arc(), state_cache.clone_arc(), block_processor.clone_arc(), event_channels, @@ -199,6 +210,7 @@ where let controller = Arc::new(Self { store_snapshot, + fcr_snapshot, block_processor, execution_engine, pubkey_cache, @@ -885,6 +897,10 @@ where self.store_snapshot.load_full() } + pub(crate) fn fcr_snapshot(&self) -> Guard>>> { + self.fcr_snapshot.load() + } + pub(crate) fn owned_storage(&self) -> Arc> { self.storage.clone_arc() } diff --git a/fork_choice_control/src/extra_tests.rs b/fork_choice_control/src/extra_tests.rs index bc29a194f..1a5183330 100644 --- a/fork_choice_control/src/extra_tests.rs +++ b/fork_choice_control/src/extra_tests.rs @@ -10,6 +10,12 @@ #![expect(clippy::similar_names)] #![expect(clippy::too_many_lines)] +#![expect( + clippy::doc_markdown, + reason = "Test docstrings narrate scenarios in prose using block/field identifiers like \ + block_1, fcr_curr_obs_justified, justified_checkpoint; backticking each one in \ + paragraph-style explanations hurts readability." +)] #[cfg(feature = "eth2-cache")] use std::sync::Arc; @@ -2527,10 +2533,11 @@ fn reorganizing_due_to_invalidation_sends_notifications_if_common_ancestor_is_un // Fast Confirmation Rule (FCR) tests // ───────────────────────────────────────────────────────────────────────────── -/// When FCR is disabled, `confirmed_root()` is an alias for `justified_checkpoint().root`. -/// This verifies the fallback path stays correct after justification advances. +/// When FCR is disabled, `confirmed_root()` returns `None`. There is no "confirmed root" +/// concept outside the Fast Confirmation Rule context; the previous alias to the justified +/// checkpoint was too weak an assumption. #[test] -fn fcr_disabled_confirmed_root_tracks_justified_checkpoint() { +fn fcr_disabled_returns_no_confirmed_root() { let mut context = Context::minimal(); let (_, state_0) = context.genesis(); @@ -2542,11 +2549,7 @@ fn fcr_disabled_confirmed_root_tracks_justified_checkpoint() { context.on_acceptable_block(&block_1); context.on_acceptable_block(&block_2); - let justified = context.justified_checkpoint(); - let confirmed = context.confirmed_root(); - - assert_eq!(confirmed, justified.root); - assert_ne!(confirmed, H256::zero()); + assert_eq!(context.confirmed_root(), None); } /// When FCR is enabled and no attestations have been cast, the confirmed root must @@ -2565,7 +2568,9 @@ fn fcr_enabled_stays_at_finalized_without_attestation_support() { context.on_acceptable_block(&block_2); let finalized = context.finalized_root(); - let confirmed = context.confirmed_root(); + let confirmed = context + .confirmed_root() + .expect("FCR is enabled — confirmed_root() must return Some"); assert_eq!(confirmed, finalized); } @@ -2605,7 +2610,9 @@ fn fcr_enabled_confirmed_is_more_conservative_than_justified() { let justified_root = context.justified_checkpoint().root; let finalized_root = context.finalized_root(); - let confirmed = context.confirmed_root(); + let confirmed = context + .confirmed_root() + .expect("FCR is enabled — confirmed_root() must return Some"); assert_ne!( justified_root, finalized_root, @@ -2682,7 +2689,9 @@ fn fcr_advances_beyond_finalized_with_supermajority() { context.on_slot(start_of_epoch(3)); let finalized_root = context.finalized_root(); - let confirmed = context.confirmed_root(); + let confirmed = context + .confirmed_root() + .expect("FCR is enabled — confirmed_root() must return Some"); assert_ne!( confirmed, finalized_root, @@ -2721,7 +2730,9 @@ fn fcr_reverts_to_finalized_when_confirmed_becomes_stale() { context.on_slot(start_of_epoch(3)); let finalized_root = context.finalized_root(); - let confirmed_at_epoch_3 = context.confirmed_root(); + let confirmed_at_epoch_3 = context + .confirmed_root() + .expect("FCR is enabled — confirmed_root() must return Some"); assert_ne!( confirmed_at_epoch_3, finalized_root, @@ -2732,7 +2743,9 @@ fn fcr_reverts_to_finalized_when_confirmed_becomes_stale() { // The age-revert condition fires: confirmed_epoch (2) + 1 = 3 < current_epoch (4). context.on_slot(start_of_epoch(4)); - let confirmed_at_epoch_4 = context.confirmed_root(); + let confirmed_at_epoch_4 = context + .confirmed_root() + .expect("FCR is enabled — confirmed_root() must return Some"); assert_eq!( confirmed_at_epoch_4, finalized_root, "FCR confirmed root should revert to finalized when confirmed is more than one epoch old", diff --git a/fork_choice_control/src/helpers.rs b/fork_choice_control/src/helpers.rs index c78c78544..941134b72 100644 --- a/fork_choice_control/src/helpers.rs +++ b/fork_choice_control/src/helpers.rs @@ -182,7 +182,7 @@ impl Context

{ } #[must_use] - pub fn confirmed_root(&self) -> H256 { + pub fn confirmed_root(&self) -> Option { self.controller().confirmed_root() } @@ -192,7 +192,7 @@ impl Context

{ } #[must_use] - pub fn justified_checkpoint(&self) -> types::phase0::containers::Checkpoint { + pub fn justified_checkpoint(&self) -> Checkpoint { self.controller().justified_checkpoint() } @@ -319,7 +319,7 @@ impl Context

{ &self, pre_state: &Arc>, block_slot: Slot, - attestation_slots: core::ops::Range, + attestation_slots: Range, graffiti: H256, ) -> (Arc>, Arc>) { factory::block_with_attestations_for_slots( @@ -606,6 +606,51 @@ impl Context

{ assert_eq!(self.controller().proposer_boost_root(), expected_root); } + pub fn assert_fcr_previous_epoch_observed_justified_checkpoint( + &self, + expected: Checkpoint, + ) { + assert_eq!( + self.controller() + .fcr_previous_epoch_observed_justified_checkpoint(), + Some(expected), + ); + } + + pub fn assert_fcr_current_epoch_observed_justified_checkpoint( + &self, + expected: Checkpoint, + ) { + assert_eq!( + self.controller() + .fcr_current_epoch_observed_justified_checkpoint(), + Some(expected), + ); + } + + pub fn assert_fcr_previous_epoch_greatest_unrealized_checkpoint( + &self, + expected: Checkpoint, + ) { + assert_eq!( + self.controller() + .fcr_previous_epoch_greatest_unrealized_checkpoint(), + Some(expected), + ); + } + + pub fn assert_fcr_previous_slot_head(&self, expected: H256) { + assert_eq!(self.controller().fcr_previous_slot_head(), Some(expected)); + } + + pub fn assert_fcr_current_slot_head(&self, expected: H256) { + assert_eq!(self.controller().fcr_current_slot_head(), Some(expected)); + } + + pub fn assert_fcr_confirmed_root(&self, expected: H256) { + assert_eq!(self.controller().confirmed_root(), Some(expected)); + } + pub fn assert_head(&self, expected_head_slot: Slot, expected_head_root: H256) { let head = self.controller().head().value; diff --git a/fork_choice_control/src/mutator.rs b/fork_choice_control/src/mutator.rs index 996e5aeb3..1274d940f 100644 --- a/fork_choice_control/src/mutator.rs +++ b/fork_choice_control/src/mutator.rs @@ -37,7 +37,8 @@ use fork_choice_store::{ AttestationItem, AttestationOrigin, AttestationValidationError, AttesterSlashingOrigin, BlobSidecarAction, BlobSidecarOrigin, BlockAction, BlockOrigin, ChainLink, DataColumnSidecarAction, DataColumnSidecarOrigin, Error, ExecutionPayloadBidAction, - ExecutionPayloadBidOrigin, PayloadAction, StateCacheProcessor, Store, ValidAttestation, + ExecutionPayloadBidOrigin, FastConfirmationStore, PayloadAction, StateCacheProcessor, Store, + ValidAttestation, }; use futures::channel::{mpsc::Sender as MultiSender, oneshot::Sender as OneshotSender}; use helper_functions::{accessors, misc, predicates, verifier::NullVerifier}; @@ -101,6 +102,8 @@ pub struct Mutator { pubkey_cache: Arc, store: Arc>>, store_snapshot: Arc>>>, + fcr_store: Option>, + fcr_snapshot: Arc>>>, state_cache: Arc>, block_processor: Arc>, event_channels: Arc>, @@ -162,6 +165,7 @@ where pub fn new( pubkey_cache: Arc, store_snapshot: Arc>>>, + fcr_snapshot: Arc>>>, state_cache: Arc>, block_processor: Arc>, event_channels: Arc>, @@ -180,10 +184,13 @@ where sync_tx: SS, validator_tx: VS, ) -> Self { + let fcr_store = fcr_snapshot.load_full().as_ref().clone(); Self { pubkey_cache, store: store_snapshot.load_full(), store_snapshot, + fcr_store, + fcr_snapshot, state_cache, block_processor, event_channels, @@ -501,6 +508,15 @@ where return Ok(()); }; + // FCR: run on_fast_confirmation once per slot, after past-slot attestations have been + // applied by `apply_tick`. Spec: `update_fast_confirmation_variables` MUST be called + // only once per slot; `is_slot_updated()` suppresses intra-slot tick updates. + if changes.is_slot_updated() + && let Some(fcr) = self.fcr_store.as_mut() + { + fcr.on_fast_confirmation(&self.store); + } + self.spawn_state_cache_prune_task( // preserve unfinalized fork tips if not finalized or epoch did not change !changes.is_finalized_checkpoint_updated() || !changes.is_epoch_updated(), @@ -2474,6 +2490,18 @@ where .apply_attester_slashing(slashable_indices)?; } + // FCR: advance confirmed root when the block caused a head change. Spec permits + // `get_latest_confirmed` to be called any number of times per slot; only + // `update_fast_confirmation_variables` is once-per-slot, and that runs in `handle_tick`. + if matches!( + changes, + ApplyBlockChanges::CanonicalChainExtended { .. } + | ApplyBlockChanges::Reorganized { .. } + ) && let Some(fcr) = self.fcr_store.as_mut() + { + fcr.on_head_change(&self.store); + } + // The snapshot should be updated: // - After calling `Mutator::archive_finalized` because it mutates the store. // - Before spawning tasks to retry delayed objects or notifying other components to ensure @@ -2752,7 +2780,7 @@ where return; } - let safe_block_hash = self.store.safe_execution_payload_hash(); + let safe_block_hash = self.safe_execution_payload_hash(); let finalized_block_hash = self.store.finalized_execution_payload_hash(); let head_block_hash = state.latest_execution_payload_header().block_hash(); @@ -3443,7 +3471,7 @@ where return; } - let safe_block_hash = self.store.safe_execution_payload_hash(); + let safe_block_hash = self.safe_execution_payload_hash(); let finalized_block_hash = self.store.finalized_execution_payload_hash(); self.send_to_validator(ValidatorMessage::PrepareExecutionPayload( @@ -3704,6 +3732,26 @@ where fn update_store_snapshot(&self) { // `ArcSwap::rcu` is not necessary here because there is only one thread mutating the store. self.store_snapshot.store(self.owned_store()); + // Publish the FCR snapshot alongside the store so readers always see a consistent pair. + self.fcr_snapshot.store(Arc::new(self.fcr_store.clone())); + } + + /// Returns the "safe" tag block hash from the execution payload. When FCR is enabled, this + /// resolves to the FCR-confirmed block's execution hash; otherwise to the justified block's. + /// Equivalent to `Snapshot::safe_execution_payload_hash` but operates on `Mutator`'s own + /// mid-flight state rather than the published snapshot. + fn safe_execution_payload_hash(&self) -> ExecutionBlockHash { + if let Some(fcr) = self.fcr_store.as_ref() { + return self + .store + .chain_link(fcr.confirmed_root()) + .and_then(ChainLink::execution_block_hash) + .unwrap_or_default(); + } + self.store + .justified_chain_link() + .and_then(ChainLink::execution_block_hash) + .unwrap_or_default() } fn spawn(&self, task: impl Spawn) { diff --git a/fork_choice_control/src/queries.rs b/fork_choice_control/src/queries.rs index 4afdfa6fe..5f9377125 100644 --- a/fork_choice_control/src/queries.rs +++ b/fork_choice_control/src/queries.rs @@ -7,7 +7,8 @@ use eth2_libp2p::GossipId; use execution_engine::ExecutionEngine; use fork_choice_store::{ AggregateAndProofOrigin, AttestationItem, BlobSidecarAction, BlobSidecarOrigin, ChainLink, - DataColumnSidecarAction, DataColumnSidecarOrigin, StateCacheProcessor, Store, + DataColumnSidecarAction, DataColumnSidecarOrigin, FastConfirmationStore, StateCacheProcessor, + Store, }; use futures::Future; use helper_functions::{accessors, misc}; @@ -83,11 +84,60 @@ where self.store_snapshot().finalized_root() } - /// Returns the current FCR-confirmed block root, or the justified checkpoint root when FCR - /// is disabled. + /// Returns the current FCR-confirmed block root when the Fast Confirmation Rule is enabled. + /// Returns `None` when FCR is disabled — there is no "confirmed root" concept outside + /// the FCR context. #[must_use] - pub fn confirmed_root(&self) -> H256 { - self.store_snapshot().confirmed_root() + pub fn confirmed_root(&self) -> Option { + self.fcr_snapshot() + .as_ref() + .as_ref() + .map(FastConfirmationStore::confirmed_root) + } + + /// Returns the FCR `previous_epoch_observed_justified_checkpoint` when FCR is enabled. + #[must_use] + pub fn fcr_previous_epoch_observed_justified_checkpoint(&self) -> Option { + self.fcr_snapshot() + .as_ref() + .as_ref() + .map(FastConfirmationStore::previous_epoch_observed_justified_checkpoint) + } + + /// Returns the FCR `current_epoch_observed_justified_checkpoint` when FCR is enabled. + #[must_use] + pub fn fcr_current_epoch_observed_justified_checkpoint(&self) -> Option { + self.fcr_snapshot() + .as_ref() + .as_ref() + .map(FastConfirmationStore::current_epoch_observed_justified_checkpoint) + } + + /// Returns the FCR `previous_epoch_greatest_unrealized_checkpoint` when FCR is enabled. + #[must_use] + pub fn fcr_previous_epoch_greatest_unrealized_checkpoint(&self) -> Option { + self.fcr_snapshot() + .as_ref() + .as_ref() + .map(FastConfirmationStore::previous_epoch_greatest_unrealized_checkpoint) + } + + /// Returns the FCR `previous_slot_head` when FCR is enabled. + #[must_use] + pub fn fcr_previous_slot_head(&self) -> Option { + self.fcr_snapshot() + .as_ref() + .as_ref() + .map(FastConfirmationStore::previous_slot_head) + } + + /// Returns the FCR `current_slot_head` when FCR is enabled. + #[must_use] + pub fn fcr_current_slot_head(&self) -> Option { + self.fcr_snapshot() + .as_ref() + .as_ref() + .map(FastConfirmationStore::current_slot_head) } #[must_use] @@ -238,15 +288,18 @@ where }) .collect(); - let extra_data = store - .store_config() - .fast_confirmation_rule - .then(|| FcrExtraData { - confirmed_root: store.confirmed_root(), - current_epoch_observed_justified_checkpoint: store.fcr_curr_obs_justified(), - previous_epoch_greatest_unrealized_checkpoint: store.fcr_prev_gu_checkpoint(), - previous_slot_head: store.fcr_prev_slot_head(), - current_slot_head: store.fcr_curr_slot_head(), + let extra_data = self + .fcr_snapshot() + .as_ref() + .as_ref() + .map(|fcr| FcrExtraData { + confirmed_root: fcr.confirmed_root(), + current_epoch_observed_justified_checkpoint: fcr + .current_epoch_observed_justified_checkpoint(), + previous_epoch_greatest_unrealized_checkpoint: fcr + .previous_epoch_greatest_unrealized_checkpoint(), + previous_slot_head: fcr.previous_slot_head(), + current_slot_head: fcr.current_slot_head(), }); ForkChoiceContext { @@ -843,6 +896,7 @@ where Snapshot { pubkey_cache: self.pubkey_cache().clone_arc(), store_snapshot: self.store_snapshot(), + fcr_snapshot: self.fcr_snapshot(), state_cache: self.state_cache().clone_arc(), storage: self.storage(), } @@ -1127,6 +1181,7 @@ pub struct Snapshot<'storage, P: Preset> { // Use a `Guard` instead of an owned snapshot unlike in tasks based on the intuition that // `Snapshot`s will be less common than tasks. store_snapshot: Guard>>>, + fcr_snapshot: Guard>>>, state_cache: Arc>, storage: &'storage Storage

, } @@ -1213,9 +1268,26 @@ impl Snapshot<'_, P> { self.store_snapshot.is_forward_synced() } + /// Returns the execution payload hash exposed as the `"safe"` tag. + /// + /// When FCR is enabled, resolves the FCR-confirmed block via `FastConfirmationStore`. + /// Otherwise falls back to the justified block's execution payload hash. + /// + /// Related: #[must_use] pub fn safe_execution_payload_hash(&self) -> ExecutionBlockHash { - self.store_snapshot.safe_execution_payload_hash() + let store = &*self.store_snapshot; + let fcr_guard = self.fcr_snapshot.as_ref().as_ref(); + if let Some(fcr) = fcr_guard { + return store + .chain_link(fcr.confirmed_root()) + .and_then(ChainLink::execution_block_hash) + .unwrap_or_default(); + } + store + .justified_chain_link() + .and_then(ChainLink::execution_block_hash) + .unwrap_or_default() } #[must_use] diff --git a/fork_choice_control/src/spec_tests.rs b/fork_choice_control/src/spec_tests.rs index 51cc3bb3b..42bf0396b 100644 --- a/fork_choice_control/src/spec_tests.rs +++ b/fork_choice_control/src/spec_tests.rs @@ -2,7 +2,7 @@ use std::{path::PathBuf, sync::Arc}; use clock::Tick; use duplicate::duplicate_item; -use execution_engine::PayloadStatusWithBlockHash; +use execution_engine::{PayloadStatusV1, PayloadStatusWithBlockHash, PayloadValidationStatus}; use helper_functions::misc; use pubkey_cache::PubkeyCache; use serde::Deserialize; @@ -25,7 +25,10 @@ use types::{ primitives::{H256, Slot, UnixSeconds}, }, preset::{Mainnet, Minimal, Preset}, - traits::{BeaconState as _, BlockBodyWithBlobKzgCommitments, SignedBeaconBlock as _}, + traits::{ + BeaconState as _, BlockBodyWithBlobKzgCommitments, BlockBodyWithExecutionPayload as _, + ExecutionPayload as _, SignedBeaconBlock as _, + }, }; use crate::helpers::Context; @@ -68,6 +71,15 @@ struct Checks { justified_checkpoint: Option, finalized_checkpoint: Option, proposer_boost_root: Option, + + // FCR checks — present only in `tests/*/phase0/fast_confirmation/*/*/*` vectors. + // See `tests/formats/fast_confirmation/README.md` in `consensus-specs`. + previous_epoch_observed_justified_checkpoint: Option, + current_epoch_observed_justified_checkpoint: Option, + previous_epoch_greatest_unrealized_checkpoint: Option, + previous_slot_head: Option, + current_slot_head: Option, + confirmed_root: Option, } #[derive(Deserialize)] @@ -158,12 +170,34 @@ fn function_name(case: Case<'_>) { let config = Arc::new(preset::default_config().start_and_stay_in(Phase::phase)); rt.block_on(async { - run_case::(&config, case).await; + run_case::(&config, case, false).await; + }); +} + +// Fast Confirmation Rule spec-test vectors (added in `consensus-specs` v1.7.0-alpha.5). +// Per the release layout, FCR vectors exist only under `tests/minimal//fast_confirmation/`; +// mainnet presets are not generated. See `tests/formats/fast_confirmation/README.md`. +#[duplicate_item( + glob function_name preset phase; + ["consensus-spec-tests/tests/minimal/altair/fast_confirmation/*/*/*"] [altair_minimal_fcr] [Minimal] [Altair]; + ["consensus-spec-tests/tests/minimal/bellatrix/fast_confirmation/*/*/*"] [bellatrix_minimal_fcr] [Minimal] [Bellatrix]; + ["consensus-spec-tests/tests/minimal/capella/fast_confirmation/*/*/*"] [capella_minimal_fcr] [Minimal] [Capella]; + ["consensus-spec-tests/tests/minimal/deneb/fast_confirmation/*/*/*"] [deneb_minimal_fcr] [Minimal] [Deneb]; + ["consensus-spec-tests/tests/minimal/electra/fast_confirmation/*/*/*"] [electra_minimal_fcr] [Minimal] [Electra]; + ["consensus-spec-tests/tests/minimal/fulu/fast_confirmation/*/*/*"] [fulu_minimal_fcr] [Minimal] [Fulu]; +)] +#[test_resources(glob)] +fn function_name(case: Case<'_>) { + let rt = tokio::runtime::Runtime::new().expect("Tokio runtime starts successfully in tests"); + let config = Arc::new(preset::default_config().start_and_stay_in(Phase::phase)); + + rt.block_on(async { + run_case::(&config, case, true).await; }); } #[expect(clippy::too_many_lines)] -async fn run_case(config: &Arc, case: Case<'_>) { +async fn run_case(config: &Arc, case: Case<'_>, fast_confirmation_rule: bool) { let anchor_block = case .ssz::<_, BeaconBlock

>(config.as_ref(), "anchor_block") .with_zero_signature() @@ -185,7 +219,7 @@ async fn run_case(config: &Arc, case: Case<'_>) { anchor_block, anchor_state, false, - false, + fast_confirmation_rule, ); let mut last_payload_status: Option = None; @@ -280,6 +314,33 @@ async fn run_case(config: &Arc, case: Case<'_>) { } else { context.on_invalid_block(&block); } + + // FCR spec-test path: the pyspec test generator's `add_block` is atomic + // (see `consensus-specs/tests/core/pyspec/.../helpers/fork_choice.py`) — it + // calls `spec.on_block` directly and emits no `payload_status` step. Grandine + // however defaults post-merge blocks to `PayloadStatus::Optimistic` + // (`initial_payload_status` in `store.rs`) until the EL confirms. Since + // `is_one_confirmed` MUST reject non-VALID blocks per the optimistic-sync spec, + // we promote the payload to VALID here so the FCR check logic can see the + // same world the pyspec does. Gated on `fast_confirmation_rule` so this does + // not affect existing fork_choice tests. + if fast_confirmation_rule && valid && let Some(payload) = block + .message() + .body() + .with_execution_payload() + .map(|body| body.execution_payload()) + { + let payload_status = PayloadStatusV1 { + status: PayloadValidationStatus::Valid, + latest_valid_hash: Some(payload.block_hash()), + validation_error: None, + }; + context.on_notified_new_payload( + beacon_block_root, + payload.block_hash(), + payload_status, + ); + } } Step::MergeBlock { pow_block } => { let block_hash = pow_block @@ -326,6 +387,12 @@ async fn run_case(config: &Arc, case: Case<'_>) { justified_checkpoint, finalized_checkpoint, proposer_boost_root, + previous_epoch_observed_justified_checkpoint, + current_epoch_observed_justified_checkpoint, + previous_epoch_greatest_unrealized_checkpoint, + previous_slot_head, + current_slot_head, + confirmed_root, } = *checks; if let Some(HeadCheck { slot, root }) = head { @@ -352,6 +419,31 @@ async fn run_case(config: &Arc, case: Case<'_>) { if let Some(proposer_boost_root) = proposer_boost_root { context.assert_proposer_boost_root(proposer_boost_root); } + + // FCR checks — only populated by `fast_confirmation/*` test vectors. + if let Some(checkpoint) = previous_epoch_observed_justified_checkpoint { + context.assert_fcr_previous_epoch_observed_justified_checkpoint(checkpoint); + } + + if let Some(checkpoint) = current_epoch_observed_justified_checkpoint { + context.assert_fcr_current_epoch_observed_justified_checkpoint(checkpoint); + } + + if let Some(checkpoint) = previous_epoch_greatest_unrealized_checkpoint { + context.assert_fcr_previous_epoch_greatest_unrealized_checkpoint(checkpoint); + } + + if let Some(root) = previous_slot_head { + context.assert_fcr_previous_slot_head(root); + } + + if let Some(root) = current_slot_head { + context.assert_fcr_current_slot_head(root); + } + + if let Some(root) = confirmed_root { + context.assert_fcr_confirmed_root(root); + } } } } diff --git a/fork_choice_store/src/fast_confirmation.rs b/fork_choice_store/src/fast_confirmation.rs deleted file mode 100644 index b1cc5ea63..000000000 --- a/fork_choice_store/src/fast_confirmation.rs +++ /dev/null @@ -1,433 +0,0 @@ -/// Fast Confirmation Rule (FCR) constants, types, and free functions. -/// -/// See the [FCR specification](https://github.com/ethereum/consensus-specs/pull/4747). -use arithmetic::NonZeroExt as _; -use helper_functions::misc; -use typenum::Unsigned as _; -use types::{ - phase0::{ - containers::Checkpoint, - primitives::{Epoch, Gwei, H256, Slot}, - }, - preset::Preset, -}; - -/// Assumed maximum percentage of Byzantine validators among the validator set. -pub const CONFIRMATION_BYZANTINE_THRESHOLD: u64 = 25; - -/// Per-mille value added to committee weight estimates for ranges not covering a full epoch, -/// to ensure safety with high probability. -/// -/// See . -pub const COMMITTEE_WEIGHT_ESTIMATION_ADJUSTMENT_FACTOR: u64 = 5; - -/// Pre-computed per-block data built by `Store::fcr_build_chain_info`. -#[derive(Debug, Clone)] -pub struct ChainInfo { - pub block_root: H256, - pub slot: Slot, - /// Slot of this block's parent. - pub parent_slot: Slot, - /// Epoch of this block's slot. - pub epoch: Epoch, - /// Voting source epoch: `unrealized_justified_checkpoint.epoch` for prev-epoch blocks, - /// `store.justified_checkpoint.epoch` for current-epoch blocks. - pub voting_source_epoch: Epoch, - /// Whether `store.fcr_prev_slot_head` has this block as an ancestor. - pub seen_by_prev_head: bool, - /// spec: `get_attestation_score` — total active balance of validators whose latest - /// message is a descendant of this block. - pub support: Gwei, - /// spec: `get_equivocation_score` — total balance of equivocating validators assigned - /// to committees in the adversarial slot range for this block. - pub adversarial: Gwei, - /// Maximum possible committee weight from `parent_slot + 1` to `current_slot - 1`. - /// Used as `maximum_support` in `compute_safety_threshold`. - pub committee_weight: Gwei, - /// Maximum possible committee weight for the adversarial slot range - /// (`adv_start` to `current_slot - 1`). Used in `compute_adversarial_weight`. - pub adv_committee_weight: Gwei, - /// spec: `compute_proposer_score`. - pub proposer_score: Gwei, - /// spec: `compute_empty_slot_support_discount` — pre-computed because it needs - /// `get_block_support_between_slots` for the parent in empty slots. - pub support_discount: Gwei, - /// `false` if this block's payload status is not VALID (i.e. optimistic). - /// `is_one_confirmed` MUST return `false` for non-VALID blocks per the optimistic sync spec. - pub is_valid: bool, -} - -/// FFG-related state built by `Store::fcr_build_ffg_data`. -#[derive(Debug, Clone)] -pub struct FcrFfgData { - /// Cached minimum honest FFG support for the current epoch target. - pub honest_ffg_support: Gwei, - /// Cached total active balance from the current balance source. - pub total_active_balance: Gwei, - pub prev_obs_justified: Checkpoint, - pub curr_obs_justified: Checkpoint, - /// The store's current `unrealized_justified_checkpoint` (for the spec shortcut). - pub unrealized_justified_checkpoint: Checkpoint, - /// Ancestor of the current head at the current epoch start slot. - pub current_target: Option, - /// spec: `get_current_target_score` — pre-computed by `Store::fcr_build_ffg_data` - /// because it requires `latest_messages` and `ancestor()`. - pub current_target_score: Gwei, - /// Committee weight from epoch start to `current_slot - 1` (used by - /// `compute_honest_ffg_support_for_current_target`). - pub ffg_weight_till_now: Gwei, - /// Adversarial weight from epoch start to `current_slot - 1`. - pub adversarial_this_epoch: Gwei, -} - -/// Diagnostic data for a block that failed `is_one_confirmed`. -#[derive(Debug, Clone)] -pub struct FcrDiagnostics { - pub block_root: H256, - pub slot: Slot, - pub support: Gwei, - pub threshold: Gwei, - pub reason: &'static str, -} - -// ============================================================ -// spec: `estimate_committee_weight_between_slots` -// ============================================================ - -/// spec: `estimate_committee_weight_between_slots(total_active_balance, start_slot, end_slot)` -pub fn estimate_committee_weight_between_slots( - total_active_balance: Gwei, - start_slot: Slot, - end_slot: Slot, -) -> Gwei { - if start_slot > end_slot { - return 0; - } - - // is_full_validator_set_covered: does the range contain a complete epoch? - // spec: start_full_epoch = epoch(start_slot + SLOTS_PER_EPOCH - 1) - // end_full_epoch = epoch(end_slot + 1) - // covered if start_full_epoch < end_full_epoch - let spe = P::SlotsPerEpoch::U64; - let start_full_epoch = misc::compute_epoch_at_slot::

(start_slot.saturating_add(spe - 1)); - let end_full_epoch = misc::compute_epoch_at_slot::

(end_slot.saturating_add(1)); - if start_full_epoch < end_full_epoch { - return total_active_balance; - } - - let start_epoch = misc::compute_epoch_at_slot::

(start_slot); - let end_epoch = misc::compute_epoch_at_slot::

(end_slot); - let committee_weight = total_active_balance / P::SlotsPerEpoch::non_zero(); - - if start_epoch == end_epoch { - committee_weight * (end_slot - start_slot + 1) - } else { - // Spans epoch boundary but doesn't cover a full epoch — pro-rata calculation - let num_slots_in_end_epoch = misc::slots_since_epoch_start::

(end_slot) + 1; - let remaining_slots_in_end_epoch = spe - num_slots_in_end_epoch; - let num_slots_in_start_epoch = spe - misc::slots_since_epoch_start::

(start_slot); - - let start_epoch_weight = committee_weight * num_slots_in_start_epoch; - let end_epoch_weight = committee_weight * num_slots_in_end_epoch; - - // pro-rated: start_epoch_weight * remaining_in_end / SLOTS_PER_EPOCH - let start_epoch_weight_pro_rated = start_epoch_weight / spe * remaining_slots_in_end_epoch; - - let raw_estimate = start_epoch_weight_pro_rated + end_epoch_weight; - - // adjust_committee_weight_estimate_to_ensure_safety: - // ceil(estimate / 1000) * (1000 + ADJUSTMENT_FACTOR) - (raw_estimate + 999) / 1000 * (1000 + COMMITTEE_WEIGHT_ESTIMATION_ADJUSTMENT_FACTOR) - } -} - -// ============================================================ -// spec: `compute_proposer_score` -// ============================================================ - -/// spec: `compute_proposer_score(balance_source)` -pub fn compute_proposer_score( - total_active_balance: Gwei, - proposer_score_boost: u64, -) -> Gwei { - let committee_weight = total_active_balance / P::SlotsPerEpoch::non_zero(); - committee_weight * proposer_score_boost / 100 -} - -// ============================================================ -// spec: `compute_adversarial_weight` -// ============================================================ - -/// spec: `compute_adversarial_weight(store, balance_source, start_slot, end_slot)` -/// -/// `adv_committee_weight` — estimated committee weight for the adversarial slot range -/// (pre-computed into `ChainInfo.adv_committee_weight` by `fcr_build_chain_info`). -/// -/// `equivocation_score` — `ChainInfo.adversarial` (pre-computed equivocating balance -/// in that range by `fcr_build_chain_info`). -pub fn compute_adversarial_weight(adv_committee_weight: Gwei, equivocation_score: Gwei) -> Gwei { - let max_adversarial = adv_committee_weight / 100 * CONFIRMATION_BYZANTINE_THRESHOLD; - max_adversarial.saturating_sub(equivocation_score) -} - -// ============================================================ -// spec: `compute_empty_slot_support_discount` -// ============================================================ - -/// spec: `compute_empty_slot_support_discount(store, balance_source, block_root)` -/// -/// Uses `ChainInfo.support_discount` which is pre-computed in `fcr_build_chain_info` -/// because computing it requires `get_block_support_between_slots` for the parent block -/// (needs `latest_messages` and `beacon_committees`). -/// -/// This function exists for spec traceability; the actual computation happens during -/// pre-computation. -#[inline] -pub fn compute_empty_slot_support_discount(info: &ChainInfo) -> Gwei { - info.support_discount -} - -// ============================================================ -// spec: `compute_safety_threshold` -// ============================================================ - -/// spec: `compute_safety_threshold(store, block_root, balance_source)` -/// -/// Returns `(maximum_support + proposer_score + 2*adversarial_weight - support_discount) / 2`. -pub fn compute_safety_threshold(info: &ChainInfo) -> Gwei { - let adversarial_weight = - compute_adversarial_weight(info.adv_committee_weight, info.adversarial); - let support_discount = compute_empty_slot_support_discount(info); - - let lhs = info - .committee_weight - .saturating_add(info.proposer_score) - .saturating_add(2 * adversarial_weight); - - if support_discount < lhs { - (lhs - support_discount) / 2 - } else { - 0 - } -} - -// ============================================================ -// spec: `is_one_confirmed` -// ============================================================ - -/// spec: `is_one_confirmed(store, balance_source, block_root)` -/// -/// Per the optimistic sync spec, MUST return `false` if the block's payload status is not VALID. -pub fn is_one_confirmed(info: &ChainInfo) -> bool { - if !info.is_valid { - return false; - } - info.support > compute_safety_threshold(info) -} - -// ============================================================ -// spec: `get_voting_source_for_root` (helper absorbed into ChainInfo) -// ============================================================ - -/// spec: `get_voting_source(store, block_root)` -/// -/// Returns the voting source epoch for a block. -/// Called by `fcr_build_chain_info` to populate `ChainInfo.voting_source_epoch`; -/// also callable standalone. -pub fn get_voting_source_epoch( - block_epoch: Epoch, - current_epoch: Epoch, - unrealized_justified_epoch: Epoch, - store_justified_epoch: Epoch, -) -> Epoch { - if block_epoch < current_epoch { - unrealized_justified_epoch - } else { - store_justified_epoch - } -} - -// ============================================================ -// spec: `compute_honest_ffg_support_for_current_target` -// ============================================================ - -/// spec: `compute_honest_ffg_support_for_current_target(store)` -/// -/// `FcrFfgData.current_target_score`, `ffg_weight_till_now`, and `adversarial_this_epoch` -/// are pre-computed in `Store::fcr_build_ffg_data` because they require `latest_messages` -/// and `ancestor()`. -pub fn compute_honest_ffg_support_for_current_target(ffg: &FcrFfgData) -> Gwei { - let remaining_ffg_weight = ffg - .total_active_balance - .saturating_sub(ffg.ffg_weight_till_now); - - let remaining_honest_ffg_weight = - remaining_ffg_weight / 100 * (100 - CONFIRMATION_BYZANTINE_THRESHOLD); - - let min_honest_ffg_support = ffg - .current_target_score - .saturating_sub(ffg.adversarial_this_epoch.min(ffg.current_target_score)); - - min_honest_ffg_support + remaining_honest_ffg_weight -} - -// ============================================================ -// spec: `will_no_conflicting_checkpoint_be_justified` -// ============================================================ - -/// spec: `will_no_conflicting_checkpoint_be_justified(store)` -/// -/// Returns `true` when `3 * honest_ffg_support >= total_active_balance`. -/// Short-circuits when the current target is already the unrealized justified checkpoint. -pub fn will_no_conflicting_checkpoint_be_justified(ffg: &FcrFfgData) -> bool { - // Spec shortcut: if current target IS the unrealized justified, no conflict is possible - if let Some(current_target) = ffg.current_target { - if current_target == ffg.unrealized_justified_checkpoint { - return true; - } - } - - if ffg.total_active_balance == 0 { - return false; - } - 3 * ffg.honest_ffg_support > ffg.total_active_balance -} - -// ============================================================ -// spec: `will_current_target_be_justified` -// ============================================================ - -/// spec: `will_current_target_be_justified(store)` -/// -/// Returns `true` when `3 * honest_ffg_support >= 2 * total_active_balance`. -pub fn will_current_target_be_justified(ffg: &FcrFfgData) -> bool { - if ffg.total_active_balance == 0 { - return false; - } - 3 * ffg.honest_ffg_support >= 2 * ffg.total_active_balance -} - -// ============================================================ -// spec: `is_confirmed_chain_safe` -// ============================================================ - -/// spec: `is_confirmed_chain_safe(store, confirmed_root)` -/// -/// `chain` must be built with the PREVIOUS epoch balance source. -pub fn is_confirmed_chain_safe(chain: &[ChainInfo]) -> bool { - chain.iter().all(is_one_confirmed) -} - -// ============================================================ -// spec: `find_latest_confirmed_descendant` -// ============================================================ - -/// spec: `find_latest_confirmed_descendant(store, latest_confirmed_root)` -/// -/// - `chain`: blocks from `confirmed_root` (exclusive) toward head (inclusive), oldest-first. -/// - `confirmed_epoch`: epoch of the initial `confirmed_root`. -/// - `current_slot_is_epoch_start`: `misc::is_epoch_start(current_slot)`. -/// - `prev_head_voting_source_epoch`: `ChainInfo.voting_source_epoch` of `store.fcr_prev_slot_head`. -/// - `prev_head_unrealized_justified_epoch`: unrealized justified epoch of `store.fcr_prev_slot_head`. -/// - `head_unrealized_justified_epoch`: unrealized justified epoch of the current head. -pub fn find_latest_confirmed_descendant( - chain: &[ChainInfo], - confirmed_root: H256, - confirmed_epoch: Epoch, - current_epoch: Epoch, - current_slot_is_epoch_start: bool, - ffg: &FcrFfgData, - prev_head_voting_source_epoch: Epoch, - prev_head_unrealized_justified_epoch: Epoch, - head_unrealized_justified_epoch: Epoch, -) -> H256 { - let mut confirmed_root = confirmed_root; - let mut confirmed_epoch = confirmed_epoch; - - // ---- Loop 1 guard ---- - // Advance through previous-epoch blocks when: - // - confirmed is from previous epoch - // - previous_slot_head's voting source is recent (epoch+2 >= current) - // - FFG condition: epoch start OR (no conflicting justification can happen AND - // either the previous head or current head unrealized-justifies the previous epoch) - let loop1_guard = confirmed_epoch + 1 == current_epoch - && prev_head_voting_source_epoch + 2 >= current_epoch - && (current_slot_is_epoch_start - || (will_no_conflicting_checkpoint_be_justified(ffg) - && (prev_head_unrealized_justified_epoch + 1 >= current_epoch - || head_unrealized_justified_epoch + 1 >= current_epoch))); - - if loop1_guard { - for info in chain { - // Stop at current epoch — Loop 2 handles those - if info.epoch == current_epoch { - break; - } - - // The previous head must have seen this block - if !info.seen_by_prev_head { - break; - } - - if !is_one_confirmed(info) { - break; - } - - confirmed_root = info.block_root; - confirmed_epoch = info.epoch; - } - } - - // ---- Loop 2 guard ---- - // Advance through current-epoch blocks when: - // - it's the epoch start, OR - // - the head's unrealized justification covers the previous epoch - let loop2_guard = - current_slot_is_epoch_start || head_unrealized_justified_epoch + 1 >= current_epoch; - - if loop2_guard { - // Start from after the block Loop 1 advanced to (or from beginning if no advancement) - let loop2_start = chain - .iter() - .position(|c| c.block_root == confirmed_root) - .map(|i| i + 1) - .unwrap_or(0); - - let mut tentative_root = confirmed_root; - let mut tentative_epoch = confirmed_epoch; - - for info in &chain[loop2_start..] { - // First block crossing into current epoch: need FFG guarantee - if info.epoch > tentative_epoch && info.epoch == current_epoch { - if !will_current_target_be_justified(ffg) { - break; - } - } - - if !is_one_confirmed(info) { - break; - } - - tentative_root = info.block_root; - tentative_epoch = info.epoch; - } - - // Final gate: can we commit to tentative_root? - let tentative_vs_epoch = chain - .iter() - .find(|c| c.block_root == tentative_root) - .map(|c| c.voting_source_epoch) - .unwrap_or(0); - - let can_advance = tentative_epoch == current_epoch - || (tentative_vs_epoch + 2 >= current_epoch - && (current_slot_is_epoch_start - || will_no_conflicting_checkpoint_be_justified(ffg))); - - if can_advance { - confirmed_root = tentative_root; - } - } - - confirmed_root -} diff --git a/fork_choice_store/src/fast_confirmation/mod.rs b/fork_choice_store/src/fast_confirmation/mod.rs new file mode 100644 index 000000000..ebb580a1c --- /dev/null +++ b/fork_choice_store/src/fast_confirmation/mod.rs @@ -0,0 +1,24 @@ +//! Fast Confirmation Rule (FCR) — isolated from the fork-choice `Store`. +//! +//! Entry points live on `FastConfirmationStore` in [`store`](self::store); all math helpers +//! are free functions in [`rules`](self::rules) operating on the pre-computed types defined in +//! [`types`](self::types). +//! +//! Spec: + +mod rules; +mod store; +mod types; + +// `pub` here is equivalent to `pub(crate)` because `mod fast_confirmation` is crate-private +// at the crate root (see `lib.rs`). `Store::fcr_build_chain_info` and `fcr_build_ffg_data` +// reach these via the `fast_confirmation::NAME` shortcut; external crates never see them. +pub use self::rules::{ + compute_adversarial_weight, compute_proposer_score, estimate_committee_weight_between_slots, + get_voting_source_epoch, +}; +pub use self::types::{ChainInfo, FcrFfgData}; + +// `FastConfirmationStore` is re-exported to external crates through `crate::lib.rs`'s +// `pub use crate::fast_confirmation::FastConfirmationStore`. +pub use self::store::FastConfirmationStore; diff --git a/fork_choice_store/src/fast_confirmation/rules.rs b/fork_choice_store/src/fast_confirmation/rules.rs new file mode 100644 index 000000000..30c856c7c --- /dev/null +++ b/fork_choice_store/src/fast_confirmation/rules.rs @@ -0,0 +1,328 @@ +//! Pure-math helpers used by the Fast Confirmation Rule. +//! +//! Every function here operates on pre-computed `ChainInfo` / `FcrFfgData` inputs and does not +//! touch the fork-choice `Store`. The per-slot pre-computation that builds those inputs lives +//! on `FastConfirmationStore`. + +use arithmetic::NonZeroExt as _; +use helper_functions::misc; +use typenum::Unsigned as _; +use types::{ + phase0::primitives::{Epoch, Gwei, H256, Slot}, + preset::Preset, +}; + +use super::types::{COMMITTEE_WEIGHT_ESTIMATION_ADJUSTMENT_FACTOR, ChainInfo, FcrFfgData}; + +/// Returns an estimate of the total committee weight between two slots (inclusive of both). +/// +/// Roughly corresponds to [`estimate_committee_weight_between_slots`] from the Fast Confirmation specification. +/// +/// [`estimate_committee_weight_between_slots`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#estimate_committee_weight_between_slots +pub fn estimate_committee_weight_between_slots( + total_active_balance: Gwei, + start_slot: Slot, + end_slot: Slot, +) -> Gwei { + if start_slot > end_slot { + return 0; + } + + // is_full_validator_set_covered: does the range contain a complete epoch? + let spe = P::SlotsPerEpoch::U64; + let start_full_epoch = misc::compute_epoch_at_slot::

(start_slot.saturating_add(spe - 1)); + let end_full_epoch = misc::compute_epoch_at_slot::

(end_slot.saturating_add(1)); + if start_full_epoch < end_full_epoch { + return total_active_balance; + } + + let start_epoch = misc::compute_epoch_at_slot::

(start_slot); + let end_epoch = misc::compute_epoch_at_slot::

(end_slot); + let committee_weight = total_active_balance / P::SlotsPerEpoch::non_zero(); + + if start_epoch == end_epoch { + committee_weight * (end_slot - start_slot + 1) + } else { + // Spans epoch boundary but doesn't cover a full epoch — pro-rata calculation + let num_slots_in_end_epoch = misc::slots_since_epoch_start::

(end_slot) + 1; + let remaining_slots_in_end_epoch = spe - num_slots_in_end_epoch; + let num_slots_in_start_epoch = spe - misc::slots_since_epoch_start::

(start_slot); + + let start_epoch_weight = committee_weight * num_slots_in_start_epoch; + let end_epoch_weight = committee_weight * num_slots_in_end_epoch; + + let start_epoch_weight_pro_rated = start_epoch_weight / spe * remaining_slots_in_end_epoch; + + let raw_estimate = start_epoch_weight_pro_rated + end_epoch_weight; + + // adjust_committee_weight_estimate_to_ensure_safety: + // ceil(estimate / 1000) * (1000 + ADJUSTMENT_FACTOR) + raw_estimate.div_ceil(1000) * (1000 + COMMITTEE_WEIGHT_ESTIMATION_ADJUSTMENT_FACTOR) + } +} + +/// Returns the proposer boost weight derived from total active balance. +/// +/// Roughly corresponds to [`compute_proposer_score`] from the Fork Choice specification. +/// +/// [`compute_proposer_score`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fork-choice.md#compute_proposer_score +pub fn compute_proposer_score( + total_active_balance: Gwei, + proposer_score_boost: u64, +) -> Gwei { + let committee_weight = total_active_balance / P::SlotsPerEpoch::non_zero(); + committee_weight * proposer_score_boost / 100 +} + +/// Returns the maximum adversarial weight bounded by `byzantine_threshold` (percent), discounted by already-equivocating validators. +/// +/// Roughly corresponds to [`compute_adversarial_weight`] from the Fast Confirmation specification. +/// +/// [`compute_adversarial_weight`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#compute_adversarial_weight +pub const fn compute_adversarial_weight( + adv_committee_weight: Gwei, + equivocation_score: Gwei, + byzantine_threshold: u64, +) -> Gwei { + let max_adversarial = adv_committee_weight / 100 * byzantine_threshold; + max_adversarial.saturating_sub(equivocation_score) +} + +/// Returns the empty-slot support discount for a block. The value is pre-computed and exposed here for spec traceability. +/// +/// Roughly corresponds to [`compute_empty_slot_support_discount`] from the Fast Confirmation specification. +/// +/// [`compute_empty_slot_support_discount`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#compute_empty_slot_support_discount +#[inline] +pub const fn compute_empty_slot_support_discount(info: &ChainInfo) -> Gwei { + info.support_discount +} + +/// Computes the LMD-GHOST safety threshold: `(maximum_support + proposer_score + 2 * adversarial_weight - support_discount) / 2`. +/// +/// Roughly corresponds to [`compute_safety_threshold`] from the Fast Confirmation specification. +/// +/// [`compute_safety_threshold`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#compute_safety_threshold +pub const fn compute_safety_threshold(info: &ChainInfo) -> Gwei { + let adversarial_weight = compute_adversarial_weight( + info.adv_committee_weight, + info.adversarial, + info.byzantine_threshold, + ); + let support_discount = compute_empty_slot_support_discount(info); + + let lhs = info + .committee_weight + .saturating_add(info.proposer_score) + .saturating_add(2 * adversarial_weight); + + if support_discount < lhs { + (lhs - support_discount) / 2 + } else { + 0 + } +} + +/// Returns `true` iff the block is LMD-GHOST safe. Returns `false` if the block's payload status is not VALID, per the optimistic sync specification. +/// +/// Roughly corresponds to [`is_one_confirmed`] from the Fast Confirmation specification. +/// +/// [`is_one_confirmed`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#is_one_confirmed +pub const fn is_one_confirmed(info: &ChainInfo) -> bool { + if !info.is_valid { + return false; + } + info.support > compute_safety_threshold(info) +} + +/// Returns the voting source epoch for a block. +/// +/// Roughly corresponds to [`get_voting_source`] from the Fork Choice specification. +/// +/// [`get_voting_source`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fork-choice.md#get_voting_source +pub const fn get_voting_source_epoch( + block_epoch: Epoch, + current_epoch: Epoch, + unrealized_justified_epoch: Epoch, + store_justified_epoch: Epoch, +) -> Epoch { + if block_epoch < current_epoch { + unrealized_justified_epoch + } else { + store_justified_epoch + } +} + +/// Computes the minimum honest FFG support for the current epoch target. +/// +/// Roughly corresponds to [`compute_honest_ffg_support_for_current_target`] from the Fast Confirmation specification. +/// +/// [`compute_honest_ffg_support_for_current_target`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#compute_honest_ffg_support_for_current_target +pub fn compute_honest_ffg_support_for_current_target(ffg: &FcrFfgData) -> Gwei { + let remaining_ffg_weight = ffg + .total_active_balance + .saturating_sub(ffg.ffg_weight_till_now); + + let remaining_honest_ffg_weight = remaining_ffg_weight / 100 * (100 - ffg.byzantine_threshold); + + let min_honest_ffg_support = ffg + .current_target_score + .saturating_sub(ffg.adversarial_this_epoch.min(ffg.current_target_score)); + + min_honest_ffg_support + remaining_honest_ffg_weight +} + +/// Returns `true` iff no checkpoint conflicting with the current target can ever be justified. +/// +/// Roughly corresponds to [`will_no_conflicting_checkpoint_be_justified`] from the Fast Confirmation specification. +/// +/// [`will_no_conflicting_checkpoint_be_justified`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#will_no_conflicting_checkpoint_be_justified +pub fn will_no_conflicting_checkpoint_be_justified(ffg: &FcrFfgData) -> bool { + // Spec shortcut: if current target IS the unrealized justified, no conflict is possible + if let Some(current_target) = ffg.current_target + && current_target == ffg.unrealized_justified_checkpoint + { + return true; + } + + if ffg.total_active_balance == 0 { + return false; + } + 3 * ffg.honest_ffg_support > ffg.total_active_balance +} + +/// Returns `true` iff the current target will eventually be justified. +/// +/// Roughly corresponds to [`will_current_target_be_justified`] from the Fast Confirmation specification. +/// +/// [`will_current_target_be_justified`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#will_current_target_be_justified +pub const fn will_current_target_be_justified(ffg: &FcrFfgData) -> bool { + if ffg.total_active_balance == 0 { + return false; + } + 3 * ffg.honest_ffg_support >= 2 * ffg.total_active_balance +} + +/// Returns `true` iff every block in the confirmed chain passes `is_one_confirmed`. The `chain` argument must be built with the previous epoch balance source. +/// +/// Roughly corresponds to [`is_confirmed_chain_safe`] from the Fast Confirmation specification. +/// +/// [`is_confirmed_chain_safe`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#is_confirmed_chain_safe +pub fn is_confirmed_chain_safe(chain: &[ChainInfo]) -> bool { + chain.iter().all(is_one_confirmed) +} + +/// Returns the most recent confirmed block in the canonical chain suffix starting from `confirmed_root`. +/// +/// Roughly corresponds to [`find_latest_confirmed_descendant`] from the Fast Confirmation specification. +/// +/// Parameters: +/// - `chain`: blocks from `confirmed_root` (exclusive) toward head (inclusive), oldest-first. +/// - `confirmed_epoch`: epoch of the initial `confirmed_root`. +/// - `current_slot_is_epoch_start`: `misc::is_epoch_start(current_slot)`. +/// - `prev_head_voting_source_epoch`: `ChainInfo.voting_source_epoch` of `previous_slot_head`. +/// - `prev_head_unrealized_justified_epoch`: unrealized justified epoch of `previous_slot_head`. +/// - `head_unrealized_justified_epoch`: unrealized justified epoch of the current head. +/// +/// [`find_latest_confirmed_descendant`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#find_latest_confirmed_descendant +#[expect( + clippy::too_many_arguments, + reason = "This is a pure-math helper translated directly from the spec. Bundling the scalar \ + `prev_head_*` and `head_*` parameters into a struct would obscure the 1:1 \ + correspondence with `find_latest_confirmed_descendant` in `fast-confirmation.md`." +)] +pub fn find_latest_confirmed_descendant( + chain: &[ChainInfo], + confirmed_root: H256, + confirmed_epoch: Epoch, + current_epoch: Epoch, + current_slot_is_epoch_start: bool, + ffg: &FcrFfgData, + prev_head_voting_source_epoch: Epoch, + prev_head_unrealized_justified_epoch: Epoch, + head_unrealized_justified_epoch: Epoch, +) -> H256 { + let mut confirmed_root = confirmed_root; + let mut confirmed_epoch = confirmed_epoch; + + // Loop 1 guard: advance through previous-epoch blocks when + // - confirmed is from previous epoch + // - previous_slot_head's voting source is recent (epoch+2 >= current) + // - FFG condition: epoch start OR (no conflicting justification can happen AND + // either the previous head or current head unrealized-justifies the previous epoch) + let loop1_guard = confirmed_epoch + 1 == current_epoch + && prev_head_voting_source_epoch + 2 >= current_epoch + && (current_slot_is_epoch_start + || (will_no_conflicting_checkpoint_be_justified(ffg) + && (prev_head_unrealized_justified_epoch + 1 >= current_epoch + || head_unrealized_justified_epoch + 1 >= current_epoch))); + + if loop1_guard { + for info in chain { + if info.epoch == current_epoch { + break; + } + if !info.seen_by_prev_head { + break; + } + if !is_one_confirmed(info) { + break; + } + confirmed_root = info.block_root; + confirmed_epoch = info.epoch; + } + } + + // Loop 2 guard: advance through current-epoch blocks when + // - it's the epoch start, OR + // - the head's unrealized justification covers the previous epoch + let loop2_guard = + current_slot_is_epoch_start || head_unrealized_justified_epoch + 1 >= current_epoch; + + if loop2_guard { + // Start from after the block Loop 1 advanced to (or from beginning if no advancement) + let loop2_start = chain + .iter() + .position(|c| c.block_root == confirmed_root) + .map(|i| i + 1) + .unwrap_or(0); + + let mut tentative_root = confirmed_root; + let mut tentative_epoch = confirmed_epoch; + + for info in &chain[loop2_start..] { + // Spec: any time the loop advances to a strictly later epoch, the FFG guarantee + // must hold. (Comment in spec notes this can only be true the first time the + // algorithm advances to a current-epoch block, but the *condition* is just + // `block_epoch > tentative_confirmed_epoch` — not gated on `== current_epoch`.) + if info.epoch > tentative_epoch && !will_current_target_be_justified(ffg) { + break; + } + + if !is_one_confirmed(info) { + break; + } + + tentative_root = info.block_root; + tentative_epoch = info.epoch; + } + + // Final gate: can we commit to tentative_root? + let tentative_vs_epoch = chain + .iter() + .find(|c| c.block_root == tentative_root) + .map(|c| c.voting_source_epoch) + .unwrap_or(0); + + let can_advance = tentative_epoch == current_epoch + || (tentative_vs_epoch + 2 >= current_epoch + && (current_slot_is_epoch_start + || will_no_conflicting_checkpoint_be_justified(ffg))); + + if can_advance { + confirmed_root = tentative_root; + } + } + + confirmed_root +} diff --git a/fork_choice_store/src/fast_confirmation/store.rs b/fork_choice_store/src/fast_confirmation/store.rs new file mode 100644 index 000000000..0c55d9fdd --- /dev/null +++ b/fork_choice_store/src/fast_confirmation/store.rs @@ -0,0 +1,397 @@ +//! `FastConfirmationStore` — the state store for the Fast Confirmation Rule. +//! +//! Corresponds to [`FastConfirmationStore`] from the Fast Confirmation specification. +//! Per the spec, this holds the FCR-tracked variables and has read-only access to the fork +//! choice `Store`. In this implementation the `store` reference is passed to methods instead +//! of being held as a field (a trivial rearrangement with the same semantics). +//! +//! [`FastConfirmationStore`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#fastconfirmationstore + +use core::marker::PhantomData; +use std::sync::Arc; + +use helper_functions::misc; +use std_ext::ArcExt as _; +use transition_functions::combined; +use types::{ + combined::BeaconState, + phase0::{ + containers::Checkpoint, + primitives::{Gwei, H256}, + }, + preset::Preset, + traits::{BeaconState as _, SignedBeaconBlock as _}, +}; + +use crate::Store; +use crate::misc::{ChainLink, Storage}; + +use super::rules::{ + compute_honest_ffg_support_for_current_target, find_latest_confirmed_descendant, + get_voting_source_epoch, is_confirmed_chain_safe, +}; + +/// Returns the state at `checkpoint`'s first slot, computing it lazily from the block state +/// (advancing via `process_slots` if needed) when the cached entry is absent. +/// +/// Mirrors the spec's `state_at_checkpoint` used by FCR. Grandine's mutator only pre-caches the +/// state for `store.unrealized_justified_checkpoint`; FCR additionally needs states for the +/// observed-justified checkpoints, which are snapshotted at the last slot of the previous epoch +/// and may diverge from the store's live `unrealized_justified_checkpoint` before rotation. +fn fcr_state_at_checkpoint>( + store: &Store, + checkpoint: Checkpoint, +) -> Option>> { + if let Some(state) = store.checkpoint_state(checkpoint) { + return Some(state.clone_arc()); + } + let mut state = store.state_by_block_root(checkpoint.root)?; + let target_slot = misc::compute_start_slot_at_epoch::

(checkpoint.epoch); + if state.slot() >= target_slot { + return Some(state); + } + combined::process_slots( + store.chain_config(), + store.pubkey_cache(), + state.make_mut(), + target_slot, + ) + .ok()?; + Some(state) +} + +/// The spec's `FastConfirmationStore`, carrying all state tracked by the Fast Confirmation Rule. +/// +/// Roughly corresponds to [`FastConfirmationStore`] from the Fast Confirmation specification. +/// +/// [`FastConfirmationStore`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#fastconfirmationstore +#[derive(Debug, Clone)] +pub struct FastConfirmationStore { + /// Root of the most recent confirmed block. + confirmed_root: H256, + /// Justified checkpoint observed by all honest nodes at the start of the previous epoch, + /// assuming synchrony. + previous_epoch_observed_justified_checkpoint: Checkpoint, + /// Justified checkpoint observed by all honest nodes at the start of the current epoch, + /// assuming synchrony. + current_epoch_observed_justified_checkpoint: Checkpoint, + /// Greatest unrealized justified checkpoint at the start of the last slot of the previous + /// epoch, according to the local view. + previous_epoch_greatest_unrealized_checkpoint: Checkpoint, + /// The head at the start of the previous slot. + previous_slot_head: H256, + /// The head at the start of the current slot. + current_slot_head: H256, + /// Implementation cache — minimum honest FFG support for the current epoch target, + /// refreshed each slot by `on_fast_confirmation`. + honest_ffg_support: Gwei, + /// Implementation cache — total active balance from the current balance source, + /// refreshed each slot by `on_fast_confirmation`. + ffg_total_active_balance: Gwei, + _marker: PhantomData

, +} + +impl FastConfirmationStore

{ + /// Constructs a new FCR store anchored at the fork choice store's finalized checkpoint. + /// + /// Roughly corresponds to [`get_fast_confirmation_store`] from the Fast Confirmation specification. + /// + /// [`get_fast_confirmation_store`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#get_fast_confirmation_store + #[must_use] + pub const fn new>(store: &Store) -> Self { + let finalized = store.finalized_checkpoint(); + 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, + honest_ffg_support: 0, + ffg_total_active_balance: 0, + _marker: PhantomData, + } + } + + /// Per-slot FCR handler: updates tracking variables and advances `confirmed_root`. + /// + /// Roughly corresponds to [`on_fast_confirmation`] from the Fast Confirmation specification. + /// + /// [`on_fast_confirmation`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#on_fast_confirmation + pub fn on_fast_confirmation>(&mut self, store: &Store) { + self.update_variables(store); + self.confirmed_root = self.get_latest_confirmed(store); + } + + /// Re-runs only `get_latest_confirmed` — called when a new head is selected mid-slot. + /// The spec permits `get_latest_confirmed` to be called at any point in a slot; + /// `update_fast_confirmation_variables` MUST run only once per slot, so this entry point + /// does not invoke it. + pub fn on_head_change>(&mut self, store: &Store) { + self.confirmed_root = self.get_latest_confirmed(store); + } + + /// Rotates slot heads, snapshots the greatest-unrealized checkpoint at epoch end, + /// and rotates observed justified checkpoints at epoch start. + /// + /// Roughly corresponds to [`update_fast_confirmation_variables`] from the Fast Confirmation specification. + /// + /// [`update_fast_confirmation_variables`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#update_fast_confirmation_variables + fn update_variables>(&mut self, store: &Store) { + let current_slot = store.slot(); + let current_head = store.head().block_root; + + self.previous_slot_head = self.current_slot_head; + self.current_slot_head = current_head; + + // Last slot of epoch → snapshot greatest unrealized justified checkpoint. + // Spec: is_start_slot_at_epoch(current_slot + 1) + let is_last_slot_of_epoch = misc::is_epoch_start::

(current_slot.saturating_add(1)); + if is_last_slot_of_epoch { + self.previous_epoch_greatest_unrealized_checkpoint = + store.unrealized_justified_checkpoint(); + } + + // First slot of epoch → rotate observed justified checkpoints. + // Spec: is_start_slot_at_epoch(current_slot) + if misc::is_epoch_start::

(current_slot) { + self.previous_epoch_observed_justified_checkpoint = + self.current_epoch_observed_justified_checkpoint; + self.current_epoch_observed_justified_checkpoint = + self.previous_epoch_greatest_unrealized_checkpoint; + } + + // Refresh the per-slot FFG support cache. `fcr_state_at_checkpoint` falls back to a lazy + // `state_at_checkpoint` computation when the cached entry is absent. + let curr_obs = self.current_epoch_observed_justified_checkpoint; + let ffg_inputs = fcr_state_at_checkpoint(store, curr_obs).map(|arc| { + let balances = Store::::active_balances(&arc); + let total: Gwei = balances.iter().sum(); + (arc, balances, total) + }); + + if let Some((_state, balances, total)) = ffg_inputs { + self.ffg_total_active_balance = total; + let ffg = store.fcr_build_ffg_data( + self.current_epoch_observed_justified_checkpoint, + self.honest_ffg_support, + &balances, + total, + ); + self.honest_ffg_support = compute_honest_ffg_support_for_current_target(&ffg); + } else { + self.ffg_total_active_balance = 0; + self.honest_ffg_support = 0; + } + } + + /// Runs the FCR algorithm (revert → restart → advance) and returns the new confirmed root. + /// + /// Roughly corresponds to [`get_latest_confirmed`] from the Fast Confirmation specification. + /// + /// [`get_latest_confirmed`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#get_latest_confirmed + #[expect( + clippy::too_many_lines, + reason = "Direct translation of the five-step `get_latest_confirmed` algorithm from \ + the spec. Splitting it would obscure the 1:1 correspondence." + )] + fn get_latest_confirmed>(&self, store: &Store) -> H256 { + let current_epoch = store.current_epoch(); + let current_slot = store.slot(); + let head = store.head(); + let is_epoch_start = misc::is_epoch_start::

(current_slot); + + let confirmed_epoch = store + .chain_link(self.confirmed_root) + .map(|cl| misc::compute_epoch_at_slot::

(cl.slot())) + .unwrap_or(0); + + // confirmed_root is canonical iff ancestor of head at that slot equals confirmed_root. + let confirmed_slot = store + .chain_link(self.confirmed_root) + .map(ChainLink::slot) + .unwrap_or(0); + let confirmed_canonical = store + .ancestor(head.block_root, confirmed_slot) + .is_some_and(|a| a == self.confirmed_root); + + let mut confirmed_root = if confirmed_epoch + 1 < current_epoch || !confirmed_canonical { + store.finalized_checkpoint().root + } else if is_epoch_start { + // spec: is_confirmed_chain_safe — build chain with the previous balance source. + let safe = (|| -> Option { + let obs_slot = store + .chain_link(self.current_epoch_observed_justified_checkpoint.root)? + .slot(); + let obs_anc = store.ancestor(self.confirmed_root, obs_slot)?; + if obs_anc != self.current_epoch_observed_justified_checkpoint.root { + return Some(false); + } + + let start_root_exclusive = if self.current_epoch_observed_justified_checkpoint.epoch + + 1 + >= current_epoch + { + self.current_epoch_observed_justified_checkpoint.root + } else { + let prev_epoch = current_epoch - 1; + let prev_epoch_start = misc::compute_start_slot_at_epoch::

(prev_epoch); + let anc_root = store.ancestor(self.confirmed_root, prev_epoch_start)?; + let anc_cl = store.chain_link(anc_root)?; + let anc_epoch = misc::compute_epoch_at_slot::

(anc_cl.slot()); + if anc_epoch + 1 == current_epoch { + anc_cl.block.message().parent_root() + } else { + anc_root + } + }; + + let prev_state = fcr_state_at_checkpoint( + store, + self.previous_epoch_observed_justified_checkpoint, + )?; + let prev_balances = Store::::active_balances(&prev_state); + let prev_total: Gwei = prev_balances.iter().sum(); + + let chain = store.fcr_build_chain_info( + self.previous_slot_head, + self.confirmed_root, + start_root_exclusive, + Some(&prev_state), + &prev_balances, + prev_total, + ); + Some(is_confirmed_chain_safe(&chain)) + })() + .unwrap_or(false); + + if safe { + self.confirmed_root + } else { + store.finalized_checkpoint().root + } + } else { + self.confirmed_root + }; + + let obs = self.current_epoch_observed_justified_checkpoint; + // Use the epoch of the block obs.root points to, not checkpoint.epoch — a checkpoint + // may have epoch N while its block is from epoch N-1 (when the epoch-start slot was skipped). + let obs_slot = store + .chain_link(obs.root) + .map(ChainLink::slot) + .unwrap_or(0); + let obs_block_epoch = misc::compute_epoch_at_slot::

(obs_slot); + if is_epoch_start + && obs_block_epoch + 1 == current_epoch + && obs == head.unrealized_justified_checkpoint + { + let confirmed_slot = store + .chain_link(confirmed_root) + .map(ChainLink::slot) + .unwrap_or(0); + if confirmed_slot < obs_slot { + confirmed_root = obs.root; + } + } + + let confirmed_epoch = store + .chain_link(confirmed_root) + .map(|cl| misc::compute_epoch_at_slot::

(cl.slot())) + .unwrap_or(0); + + if confirmed_epoch + 1 < current_epoch { + return confirmed_root; + } + + let curr_state = + fcr_state_at_checkpoint(store, self.current_epoch_observed_justified_checkpoint); + + let (chain, ffg) = if let Some(state_arc) = curr_state { + let balances = Store::::active_balances(&state_arc); + let total: Gwei = balances.iter().sum(); + let chain = store.fcr_build_chain_info( + self.previous_slot_head, + head.block_root, + confirmed_root, + Some(&state_arc), + &balances, + total, + ); + let ffg = store.fcr_build_ffg_data( + self.current_epoch_observed_justified_checkpoint, + self.honest_ffg_support, + &balances, + total, + ); + (chain, ffg) + } else { + return confirmed_root; + }; + + let prev_head_voting_source_epoch = store + .chain_link(self.previous_slot_head) + .map(|cl| { + get_voting_source_epoch( + misc::compute_epoch_at_slot::

(cl.slot()), + current_epoch, + cl.unrealized_justified_checkpoint.epoch, + cl.current_justified_checkpoint.epoch, + ) + }) + .unwrap_or(0); + let prev_head_unrealized_justified_epoch = store + .chain_link(self.previous_slot_head) + .map(|cl| cl.unrealized_justified_checkpoint.epoch) + .unwrap_or(0); + let head_unrealized_justified_epoch = head.unrealized_justified_checkpoint.epoch; + + find_latest_confirmed_descendant( + &chain, + confirmed_root, + confirmed_epoch, + current_epoch, + is_epoch_start, + &ffg, + prev_head_voting_source_epoch, + prev_head_unrealized_justified_epoch, + head_unrealized_justified_epoch, + ) + } + + /// Returns the current FCR-confirmed root. + #[must_use] + pub const fn confirmed_root(&self) -> H256 { + self.confirmed_root + } + + /// Returns `store.previous_epoch_observed_justified_checkpoint`. + #[must_use] + pub const fn previous_epoch_observed_justified_checkpoint(&self) -> Checkpoint { + self.previous_epoch_observed_justified_checkpoint + } + + /// Returns `store.current_epoch_observed_justified_checkpoint`. + #[must_use] + pub const fn current_epoch_observed_justified_checkpoint(&self) -> Checkpoint { + self.current_epoch_observed_justified_checkpoint + } + + /// Returns `store.previous_epoch_greatest_unrealized_checkpoint`. + #[must_use] + pub const fn previous_epoch_greatest_unrealized_checkpoint(&self) -> Checkpoint { + self.previous_epoch_greatest_unrealized_checkpoint + } + + /// Returns `store.previous_slot_head`. + #[must_use] + pub const fn previous_slot_head(&self) -> H256 { + self.previous_slot_head + } + + /// Returns `store.current_slot_head`. + #[must_use] + pub const fn current_slot_head(&self) -> H256 { + self.current_slot_head + } +} diff --git a/fork_choice_store/src/fast_confirmation/types.rs b/fork_choice_store/src/fast_confirmation/types.rs new file mode 100644 index 000000000..0c3466e0b --- /dev/null +++ b/fork_choice_store/src/fast_confirmation/types.rs @@ -0,0 +1,70 @@ +//! Types and constants consumed by the Fast Confirmation Rule. + +use types::phase0::{ + containers::Checkpoint, + primitives::{Epoch, Gwei, H256}, +}; + +/// Per-mille value added to committee weight estimates for ranges not covering a full epoch, +/// to ensure safety with high probability. +/// +/// See . +pub const COMMITTEE_WEIGHT_ESTIMATION_ADJUSTMENT_FACTOR: u64 = 5; + +/// Pre-computed per-block data built during FCR chain construction. +#[derive(Debug, Clone)] +pub struct ChainInfo { + pub block_root: H256, + /// Epoch of this block's slot. + pub epoch: Epoch, + /// Voting source epoch: `unrealized_justified_checkpoint.epoch` for prev-epoch blocks, + /// `store.justified_checkpoint.epoch` for current-epoch blocks. + pub voting_source_epoch: Epoch, + /// Whether `previous_slot_head` has this block as an ancestor. + pub seen_by_prev_head: bool, + /// Total active balance of validators whose latest message is a descendant of this block + /// (the attestation score). + pub support: Gwei, + /// Total balance of equivocating validators assigned to committees in the adversarial slot + /// range for this block (the equivocation score). + pub adversarial: Gwei, + /// Maximum possible committee weight from `parent_slot + 1` to `current_slot - 1`. + /// Used as `maximum_support` in `compute_safety_threshold`. + pub committee_weight: Gwei, + /// Maximum possible committee weight for the adversarial slot range + /// (`adv_start` to `current_slot - 1`). Used in `compute_adversarial_weight`. + pub adv_committee_weight: Gwei, + /// Proposer boost weight for this block. + pub proposer_score: Gwei, + /// Empty-slot support discount for this block; pre-computed because it needs + /// `get_block_support_between_slots` for the parent across empty slots. + pub support_discount: Gwei, + /// `false` if this block's payload status is not VALID (i.e. optimistic). + /// `is_one_confirmed` MUST return `false` for non-VALID blocks per the optimistic sync spec. + pub is_valid: bool, + /// Value of `Config::confirmation_byzantine_threshold` (percent) at the time the chain was built. + pub byzantine_threshold: u64, +} + +/// FFG-related state built during FCR per-slot pre-computation. +#[derive(Debug, Clone)] +pub struct FcrFfgData { + /// Cached minimum honest FFG support for the current epoch target. + pub honest_ffg_support: Gwei, + /// Cached total active balance from the current balance source. + pub total_active_balance: Gwei, + /// The store's current `unrealized_justified_checkpoint` (for the spec shortcut). + pub unrealized_justified_checkpoint: Checkpoint, + /// Ancestor of the current head at the current epoch start slot. + pub current_target: Option, + /// Pre-computed current-target FFG support score. Computed during pre-computation + /// because it requires `latest_messages` and `ancestor()`. + pub current_target_score: Gwei, + /// Committee weight from epoch start to `current_slot - 1` (used by + /// `compute_honest_ffg_support_for_current_target`). + pub ffg_weight_till_now: Gwei, + /// Adversarial weight from epoch start to `current_slot - 1`. + pub adversarial_this_epoch: Gwei, + /// Value of `Config::confirmation_byzantine_threshold` (percent) at the time the data was built. + pub byzantine_threshold: u64, +} diff --git a/fork_choice_store/src/lib.rs b/fork_choice_store/src/lib.rs index 7cba6e143..277c5b5b8 100644 --- a/fork_choice_store/src/lib.rs +++ b/fork_choice_store/src/lib.rs @@ -77,6 +77,7 @@ pub use crate::{ error::Error, + fast_confirmation::FastConfirmationStore, misc::{ AggregateAndProofAction, AggregateAndProofOrigin, ApplyBlockChanges, ApplyTickChanges, AttestationAction, AttestationItem, AttestationOrigin, AttestationValidationError, @@ -95,7 +96,7 @@ pub use crate::{ mod blob_cache; mod data_column_cache; mod error; -pub mod fast_confirmation; +mod fast_confirmation; mod misc; mod segment; mod state_cache_processor; diff --git a/fork_choice_store/src/store.rs b/fork_choice_store/src/store.rs index 5cc17047c..c54d161b7 100644 --- a/fork_choice_store/src/store.rs +++ b/fork_choice_store/src/store.rs @@ -16,7 +16,7 @@ use bitvec::vec::BitVec; use anyhow::{Result, anyhow, bail, ensure}; use arithmetic::NonZeroExt as _; use bls::traits::SignatureBytes as _; -use clock::{Tick, TickKind}; +use clock::Tick; use eip_7594::{verify_data_column_sidecar, verify_kzg_proofs, verify_sidecar_inclusion_proof}; use execution_engine::ExecutionEngine; use features::Feature; @@ -269,25 +269,6 @@ pub struct Store> { delayed_block_at_slot: HashMap, requested_blobs_from_el: HashMap, current_slot_blocks_in_processing: Arc, - // === Fast Confirmation Rule (FCR) fields === - // Only meaningful when `store_config.fast_confirmation_rule` is enabled. - // spec: `confirmed_root` - fcr_confirmed_root: H256, - // spec: `previous_epoch_observed_justified_checkpoint` - fcr_prev_obs_justified: Checkpoint, - // spec: `current_epoch_observed_justified_checkpoint` - fcr_curr_obs_justified: Checkpoint, - // spec: `previous_epoch_greatest_unrealized_checkpoint` - fcr_prev_gu_checkpoint: Checkpoint, - // spec: `previous_slot_head` - fcr_prev_slot_head: H256, - // spec: `current_slot_head` - fcr_curr_slot_head: H256, - // Cached result of compute_honest_ffg_support_for_current_target, refreshed each slot. - // Avoids redundant validator-set scans when the FFG gate is queried multiple times. - fcr_honest_ffg_support: Gwei, - // Cached total active balance from the current balance source, refreshed each slot. - fcr_ffg_total_active_balance: Gwei, } impl> Store { @@ -384,15 +365,6 @@ impl> Store { delayed_block_at_slot: HashMap::default(), requested_blobs_from_el: HashMap::default(), current_slot_blocks_in_processing: Arc::new(AtomicUsize::new(0)), - // FCR: initialize confirmed_root to anchor (= finalized) block root - fcr_confirmed_root: block_root, - fcr_prev_obs_justified: checkpoint, - fcr_curr_obs_justified: checkpoint, - fcr_prev_gu_checkpoint: checkpoint, - fcr_prev_slot_head: block_root, - fcr_curr_slot_head: block_root, - fcr_honest_ffg_support: 0, - fcr_ffg_total_active_balance: 0, } } @@ -1013,7 +985,7 @@ impl> Store { /// This should never return `None` in normal operation, but the reasons for that are slightly /// different at each call site, so we call `Option::expect` every time we use this instead of /// changing the type. - fn ancestor(&self, descendant_root: H256, ancestor_slot: Slot) -> Option { + pub(crate) fn ancestor(&self, descendant_root: H256, ancestor_slot: Slot) -> Option { if let Some(location) = self.unfinalized_locations.get(&descendant_root).copied() { let descendant_segment = &self.unfinalized[&location.segment_id]; @@ -1065,6 +1037,10 @@ impl> Store { self.checkpoint_states.get(&checkpoint) } + pub fn pubkey_cache(&self) -> &Arc { + &self.pubkey_cache + } + pub fn insert_checkpoint_state(&mut self, checkpoint: Checkpoint, state: Arc>) { self.checkpoint_states .insert(checkpoint, state) @@ -1074,59 +1050,6 @@ impl> Store { ) } - /// [`get_safe_execution_payload_hash`](https://github.com/ethereum/consensus-specs/blob/v1.3.0/fork_choice/safe-block.md#get_safe_execution_payload_hash) - #[must_use] - pub fn safe_execution_payload_hash(&self) -> ExecutionBlockHash { - if self.store_config.fast_confirmation_rule && self.fcr_confirmed_root != H256::zero() { - if let Some(hash) = self - .chain_link(self.fcr_confirmed_root) - .and_then(ChainLink::execution_block_hash) - { - return hash; - } - } - self.justified_chain_link() - .and_then(ChainLink::execution_block_hash) - .unwrap_or_default() - } - - /// Returns the current confirmed block root. - /// - /// When `store_config.fast_confirmation_rule` is enabled, this is the FCR-confirmed root. - /// Otherwise falls back to the justified checkpoint root. - #[must_use] - pub fn confirmed_root(&self) -> H256 { - if self.store_config.fast_confirmation_rule { - self.fcr_confirmed_root - } else { - self.justified_checkpoint.root - } - } - - /// Returns `store.current_epoch_observed_justified_checkpoint`. - #[must_use] - pub fn fcr_curr_obs_justified(&self) -> Checkpoint { - self.fcr_curr_obs_justified - } - - /// Returns `store.previous_epoch_greatest_unrealized_checkpoint`. - #[must_use] - pub fn fcr_prev_gu_checkpoint(&self) -> Checkpoint { - self.fcr_prev_gu_checkpoint - } - - /// Returns `store.previous_slot_head`. - #[must_use] - pub fn fcr_prev_slot_head(&self) -> H256 { - self.fcr_prev_slot_head - } - - /// Returns `store.current_slot_head`. - #[must_use] - pub fn fcr_curr_slot_head(&self) -> H256 { - self.fcr_curr_slot_head - } - #[must_use] pub fn finalized_execution_payload_hash(&self) -> ExecutionBlockHash { // > As per EIP-3675, before a post-transition block is finalized, @@ -2912,11 +2835,6 @@ impl> Store { self.prune_after_finalization(); } - // FCR: run once per slot after attestations are applied and head is updated. - if self.store_config.fast_confirmation_rule { - self.fcr_on_fast_confirmation(); - } - self.blob_cache.on_slot(new_tick.slot); self.reset_current_slot_blocks_in_processing(); @@ -3061,21 +2979,6 @@ impl> Store { self.prune_after_finalization(); } - // FCR: advance confirmed when head changes. - // Note: fcr_update_fast_confirmation_variables is NOT called here — it runs once per - // slot in apply_tick. This path only re-runs get_latest_confirmed when the head moves. - if self.store_config.fast_confirmation_rule { - let new_head_root = self.head().block_root; - if new_head_root != old_head.block_root { - let new_confirmed = self.fcr_get_latest_confirmed(); - self.fcr_confirmed_root = new_confirmed; - } - // Track current_slot_head: update if block arrives before attestation deadline. - if self.tick.kind < TickKind::Attest { - self.fcr_curr_slot_head = self.head().block_root; - } - } - if !self.finished_initial_forward_sync && self.head().slot() >= self.slot() { self.finished_initial_forward_sync = true; self.state_cache.set_log_lock_timeouts(true); @@ -4043,7 +3946,7 @@ impl> Store { self.head_segment_id = best.map(|(_, segment_id)| segment_id); } - fn active_balances(state: &BeaconState

) -> Arc<[Gwei]> { + pub(crate) fn active_balances(state: &BeaconState

) -> Arc<[Gwei]> { let epoch = accessors::get_current_epoch(state); state @@ -4754,16 +4657,11 @@ impl> Store { ); } - // ========================================================================= - // Fast Confirmation Rule (FCR) — spec: fast-confirmation.md - // ========================================================================= - - /// spec: `get_equivocation_score(store, state, from_slot, to_slot)` + /// Sums active balances of equivocating validators whose committee assignments fall in `from_slot..=to_slot`. /// - /// Sums the active balances of validators that both appear in a committee - /// between `from_slot..=to_slot` AND are in `self.equivocating_indices`. + /// Roughly corresponds to [`get_equivocation_score`] from the Fast Confirmation specification. /// - /// Callers are responsible for the short-circuit when `equivocating_indices` is empty. + /// [`get_equivocation_score`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#get_equivocation_score fn fcr_get_equivocation_score( &self, state: &BeaconState

, @@ -4785,15 +4683,19 @@ impl> Store { } eq_set .iter() - .filter_map(|&i| active_balances.get(i as usize).copied()) + .filter_map(|&i| { + let idx = usize::try_from(i).ok()?; + active_balances.get(idx).copied() + }) .filter(|&b| b > 0) .sum() } - /// spec: `get_attestation_score(store, store_target, active_balances)` + /// Sums active balances of validators whose latest message descends through `root` at `slot`. + /// + /// Roughly corresponds to [`get_attestation_score`] from the Fork Choice specification. /// - /// Sums the active balances of validators whose latest message descends through - /// `root` at `slot`. + /// [`get_attestation_score`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fork-choice.md#get_attestation_score fn fcr_get_attestation_score(&self, root: H256, slot: Slot, active_balances: &[Gwei]) -> Gwei { self.latest_messages .iter() @@ -4819,10 +4721,11 @@ impl> Store { .sum() } - /// spec: `compute_empty_slot_support_discount(store, state, block, active_balances)` + /// Pre-computes the empty-slot support discount for a block; returns 0 when `parent_slot + 1 == slot`. /// - /// Computes the support discount applied when there are empty slots between - /// `parent_slot` and `slot`. Returns 0 immediately when the slots are adjacent. + /// Roughly corresponds to [`compute_empty_slot_support_discount`] from the Fast Confirmation specification. + /// + /// [`compute_empty_slot_support_discount`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#compute_empty_slot_support_discount fn fcr_precompute_support_discount( &self, parent_slot: Slot, @@ -4853,14 +4756,15 @@ impl> Store { let parent_support_in_empty: Gwei = participants .iter() .filter_map(|&i| { - let balance = *active_balances.get(i as usize)?; + let idx = usize::try_from(i).ok()?; + let balance = *active_balances.get(idx)?; if balance == 0 { return None; } if self.equivocating_indices.contains(&i) { return None; } - let msg = self.latest_messages.get(i as usize)?.as_ref()?; + let msg = self.latest_messages.get(idx)?.as_ref()?; if msg.beacon_block_root != parent_root { return None; } @@ -4881,6 +4785,7 @@ impl> Store { let adv_empty = fast_confirmation::compute_adversarial_weight( empty_committee_weight, empty_equivocation, + self.chain_config.confirmation_byzantine_threshold, ); parent_support_in_empty.saturating_sub(adv_empty) } @@ -4891,8 +4796,17 @@ impl> Store { /// This is the single O(validators) pre-computation pass. It stays on `Store` /// because it needs `self.latest_messages`, `self.equivocating_indices`, /// `self.checkpoint_states`, and `accessors::beacon_committees`. - fn fcr_build_chain_info( + /// + /// `previous_slot_head` is the `FastConfirmationStore::previous_slot_head` value, passed in + /// explicitly so this helper carries no FCR state of its own. + #[expect( + clippy::too_many_lines, + reason = "Single-pass O(validators) pre-computation; splitting would require reading the \ + same state twice." + )] + pub(crate) fn fcr_build_chain_info( &self, + previous_slot_head: H256, block_root: H256, terminal_root: H256, balance_source: Option<&BeaconState

>, @@ -4911,15 +4825,12 @@ impl> Store { let mut roots = Vec::new(); let mut current = block_root; loop { - let cl = match self.chain_link(current) { - Some(cl) => cl, - None => { - debug!( - ?current, - "FCR: ancestor not found in store while building chain" - ); - return vec![]; - } + let Some(cl) = self.chain_link(current) else { + debug!( + ?current, + "FCR: ancestor not found in store while building chain" + ); + return vec![]; }; if cl.slot() <= terminal_slot { debug!( @@ -4966,15 +4877,21 @@ impl> Store { let epoch = misc::compute_epoch_at_slot::

(slot); let parent_epoch = misc::compute_epoch_at_slot::

(parent_slot); + // Spec `get_voting_source(store, block_root)`: + // - block from prior epoch → store.unrealized_justifications[block_root] + // - block from current epoch → store.block_states[block_root].current_justified_checkpoint + // The current-epoch branch uses the BLOCK'S OWN state, not the store's global + // justified checkpoint. The two diverge on competing forks where the store has + // already absorbed a higher justification from a different chain. let voting_source_epoch = fast_confirmation::get_voting_source_epoch( epoch, current_epoch, cl.unrealized_justified_checkpoint.epoch, - self.justified_checkpoint.epoch, + cl.current_justified_checkpoint.epoch, ); let seen_by_prev_head = self - .ancestor(self.fcr_prev_slot_head, slot) + .ancestor(previous_slot_head, slot) .is_some_and(|a| a == root); // spec: get_attestation_score @@ -5045,8 +4962,6 @@ impl> Store { Some(fast_confirmation::ChainInfo { block_root: root, - slot, - parent_slot, epoch, voting_source_epoch, seen_by_prev_head, @@ -5058,6 +4973,7 @@ impl> Store { support_discount, // Per the optimistic sync spec, a block must have VALID payload to be confirmable. is_valid: !cl.is_optimistic(), + byzantine_threshold: self.chain_config.confirmation_byzantine_threshold, }) }) .collect() @@ -5067,8 +4983,13 @@ impl> Store { /// /// `get_current_target_score` stays here because it requires `self.latest_messages` /// and `self.ancestor()`. - fn fcr_build_ffg_data( + /// + /// The two snapshot params mirror `FastConfirmationStore` fields, passed in explicitly so + /// this helper carries no FCR state of its own. + pub(crate) fn fcr_build_ffg_data( &self, + curr_obs_justified: Checkpoint, + honest_ffg_support: Gwei, active_balances: &[Gwei], total_active_balance: Gwei, ) -> fast_confirmation::FcrFfgData { @@ -5133,8 +5054,8 @@ impl> Store { { match self .checkpoint_states - .get(&self.fcr_curr_obs_justified) - .map(|arc| arc.as_ref()) + .get(&curr_obs_justified) + .map(AsRef::as_ref) { Some(state) => { let adv_weight = fast_confirmation::estimate_committee_weight_between_slots::

( @@ -5143,7 +5064,7 @@ impl> Store { current_slot - 1, ); let max_adversarial = - adv_weight / 100 * fast_confirmation::CONFIRMATION_BYZANTINE_THRESHOLD; + adv_weight / 100 * self.chain_config.confirmation_byzantine_threshold; let eq_score = self.fcr_get_equivocation_score( state, epoch_start, @@ -5154,7 +5075,7 @@ impl> Store { } None => { debug!( - checkpoint = ?self.fcr_curr_obs_justified, + checkpoint = ?curr_obs_justified, "FCR: no checkpoint state for curr_obs_justified, equivocation penalty zeroed" ); 0 @@ -5165,226 +5086,14 @@ impl> Store { }; fast_confirmation::FcrFfgData { - honest_ffg_support: self.fcr_honest_ffg_support, + honest_ffg_support, total_active_balance, - prev_obs_justified: self.fcr_prev_obs_justified, - curr_obs_justified: self.fcr_curr_obs_justified, unrealized_justified_checkpoint: self.unrealized_justified_checkpoint, current_target, current_target_score, ffg_weight_till_now, adversarial_this_epoch, + byzantine_threshold: self.chain_config.confirmation_byzantine_threshold, } } - - /// spec: `update_fast_confirmation_variables(store)` - fn fcr_update_fast_confirmation_variables(&mut self) { - let current_slot = self.slot(); - let current_head = self.head().block_root; - - self.fcr_prev_slot_head = self.fcr_curr_slot_head; - self.fcr_curr_slot_head = current_head; - - // Last slot of epoch → snapshot greatest unrealized justified checkpoint - // Spec: is_start_slot_at_epoch(current_slot + 1) - let is_last_slot_of_epoch = misc::is_epoch_start::

(current_slot.saturating_add(1)); - if is_last_slot_of_epoch { - self.fcr_prev_gu_checkpoint = self.unrealized_justified_checkpoint; - } - - // First slot of epoch → rotate observed justified checkpoints - // Spec: is_start_slot_at_epoch(current_slot) - if misc::is_epoch_start::

(current_slot) { - self.fcr_prev_obs_justified = self.fcr_curr_obs_justified; - self.fcr_curr_obs_justified = self.fcr_prev_gu_checkpoint; - } - - // Refresh the per-slot FFG support cache. - // Block scope ensures checkpoint_states borrow is released before subsequent calls. - let balance_source_key = self.fcr_curr_obs_justified; - let ffg_inputs = if let Some(state) = self.checkpoint_states.get(&balance_source_key) { - let balances = Self::active_balances(state); - let total: Gwei = balances.iter().sum(); - Some((balances, total)) - } else { - None - }; - - if let Some((ref balances, total)) = ffg_inputs { - self.fcr_ffg_total_active_balance = total; - let ffg = self.fcr_build_ffg_data(balances, total); - self.fcr_honest_ffg_support = - fast_confirmation::compute_honest_ffg_support_for_current_target(&ffg); - } else { - self.fcr_ffg_total_active_balance = 0; - self.fcr_honest_ffg_support = 0; - } - } - - /// spec: `get_latest_confirmed(store)` — revert → restart → advance. - fn fcr_get_latest_confirmed(&self) -> H256 { - let current_epoch = self.current_epoch(); - let current_slot = self.slot(); - let head = self.head(); - let is_epoch_start = misc::is_epoch_start::

(current_slot); - - let confirmed_epoch = self - .chain_link(self.fcr_confirmed_root) - .map(|cl| misc::compute_epoch_at_slot::

(cl.slot())) - .unwrap_or(0); - - // confirmed_root is canonical iff ancestor of head at that slot equals confirmed_root - let confirmed_slot = self - .chain_link(self.fcr_confirmed_root) - .map(|cl| cl.slot()) - .unwrap_or(0); - let confirmed_canonical = self - .ancestor(head.block_root, confirmed_slot) - .is_some_and(|a| a == self.fcr_confirmed_root); - - let mut confirmed_root = if confirmed_epoch + 1 < current_epoch || !confirmed_canonical { - self.finalized_checkpoint.root - } else if is_epoch_start { - // spec: is_confirmed_chain_safe — build chain with PREVIOUS balance source - let safe = (|| -> Option { - // confirmed_root must descend from curr_obs_justified - let obs_slot = self.chain_link(self.fcr_curr_obs_justified.root)?.slot(); - let obs_anc = self.ancestor(self.fcr_confirmed_root, obs_slot)?; - if obs_anc != self.fcr_curr_obs_justified.root { - return Some(false); - } - - // Determine start_root_exclusive for safety chain walk - let start_root_exclusive = if self.fcr_curr_obs_justified.epoch + 1 >= current_epoch - { - self.fcr_curr_obs_justified.root - } else { - let prev_epoch = current_epoch - 1; - let prev_epoch_start = misc::compute_start_slot_at_epoch::

(prev_epoch); - let anc_root = self.ancestor(self.fcr_confirmed_root, prev_epoch_start)?; - let anc_cl = self.chain_link(anc_root)?; - let anc_epoch = misc::compute_epoch_at_slot::

(anc_cl.slot()); - if anc_epoch + 1 == current_epoch { - anc_cl.block.message().parent_root() - } else { - anc_root - } - }; - - let prev_state = - Arc::clone(self.checkpoint_states.get(&self.fcr_prev_obs_justified)?); - let prev_balances = Self::active_balances(&prev_state); - let prev_total: Gwei = prev_balances.iter().sum(); - - let chain = self.fcr_build_chain_info( - self.fcr_confirmed_root, - start_root_exclusive, - Some(&prev_state), - &prev_balances, - prev_total, - ); - Some(fast_confirmation::is_confirmed_chain_safe(&chain)) - })() - .unwrap_or(false); - - if safe { - self.fcr_confirmed_root - } else { - self.finalized_checkpoint.root - } - } else { - self.fcr_confirmed_root - }; - - let obs = self.fcr_curr_obs_justified; - // spec PR: use the epoch of the block obs.root points to, not checkpoint.epoch, - // because a checkpoint may have epoch N while its block is from epoch N-1 - // (when the epoch-start slot was skipped). - let obs_slot = self.chain_link(obs.root).map(ChainLink::slot).unwrap_or(0); - let obs_block_epoch = misc::compute_epoch_at_slot::

(obs_slot); - if is_epoch_start - && obs_block_epoch + 1 == current_epoch - && obs == head.unrealized_justified_checkpoint - { - let confirmed_slot = self - .chain_link(confirmed_root) - .map(ChainLink::slot) - .unwrap_or(0); - if confirmed_slot < obs_slot { - confirmed_root = obs.root; - } - } - - let confirmed_epoch = self - .chain_link(confirmed_root) - .map(|cl| misc::compute_epoch_at_slot::

(cl.slot())) - .unwrap_or(0); - - if confirmed_epoch + 1 < current_epoch { - debug!( - ?confirmed_root, - confirmed_epoch, current_epoch, "FCR: confirmed root too old, returning as-is" - ); - return confirmed_root; - } - - let curr_state = self - .checkpoint_states - .get(&self.fcr_curr_obs_justified) - .cloned(); - - let (chain, ffg) = if let Some(ref state) = curr_state { - let balances = Self::active_balances(state); - let total: Gwei = balances.iter().sum(); - let chain = self.fcr_build_chain_info( - head.block_root, - confirmed_root, - Some(state), - &balances, - total, - ); - let ffg = self.fcr_build_ffg_data(&balances, total); - (chain, ffg) - } else { - debug!(checkpoint = ?self.fcr_curr_obs_justified, "FCR: no balance source for curr_obs_justified, returning confirmed root"); - return confirmed_root; - }; - - let prev_head_voting_source_epoch = { - let cl = self.chain_link(self.fcr_prev_slot_head); - cl.map(|cl| { - fast_confirmation::get_voting_source_epoch( - misc::compute_epoch_at_slot::

(cl.slot()), - current_epoch, - cl.unrealized_justified_checkpoint.epoch, - self.justified_checkpoint.epoch, - ) - }) - .unwrap_or(0) - }; - let prev_head_unrealized_justified_epoch = self - .chain_link(self.fcr_prev_slot_head) - .map(|cl| cl.unrealized_justified_checkpoint.epoch) - .unwrap_or(0); - let head_unrealized_justified_epoch = head.unrealized_justified_checkpoint.epoch; - - fast_confirmation::find_latest_confirmed_descendant( - &chain, - confirmed_root, - confirmed_epoch, - current_epoch, - is_epoch_start, - &ffg, - prev_head_voting_source_epoch, - prev_head_unrealized_justified_epoch, - head_unrealized_justified_epoch, - ) - } - - /// spec: `on_fast_confirmation(store)` - fn fcr_on_fast_confirmation(&mut self) { - self.fcr_update_fast_confirmation_variables(); - let new_confirmed = self.fcr_get_latest_confirmed(); - self.fcr_confirmed_root = new_confirmed; - } } diff --git a/grandine-snapshot-tests b/grandine-snapshot-tests index e12f98ff7..19a03db64 160000 --- a/grandine-snapshot-tests +++ b/grandine-snapshot-tests @@ -1 +1 @@ -Subproject commit e12f98ff7729487be903d6a6321c565a141ef530 +Subproject commit 19a03db64758db93424ac101ffcce7fcf0e5b26c diff --git a/http_api/src/block_id.rs b/http_api/src/block_id.rs index 75f760375..8ae97026b 100644 --- a/http_api/src/block_id.rs +++ b/http_api/src/block_id.rs @@ -26,7 +26,15 @@ pub fn block( .map(|checkpoint| checkpoint.block) .map(WithStatus::valid_and_finalized), BlockId::Finalized => Some(controller.last_finalized_block()), - BlockId::Safe => controller.block_by_root(controller.confirmed_root())?, + BlockId::Safe => { + // Per the Fast Confirmation Rule: when FCR is enabled, `safe` is the FCR-confirmed + // block. When FCR is disabled there is no "confirmed" block, so we fall back to + // the finalized block (the only other block the node can guarantee is safe). + match controller.confirmed_root() { + Some(root) => controller.block_by_root(root)?, + None => Some(controller.last_finalized_block()), + } + } BlockId::Slot(slot) => controller .block_by_slot(slot)? .map(|with_status| with_status.map(|block_with_root| block_with_root.block)), @@ -49,7 +57,11 @@ pub fn block_root( .map(|checkpoint| checkpoint.block.message().hash_tree_root()) .map(WithStatus::valid_and_finalized), BlockId::Finalized => Some(controller.last_finalized_block_root()), - BlockId::Safe => controller.check_block_root(controller.confirmed_root())?, + BlockId::Safe => match controller.confirmed_root() { + Some(root) => controller.check_block_root(root)?, + // FCR disabled: safe block falls back to finalized (see `block` above). + None => Some(controller.last_finalized_block_root()), + }, BlockId::Slot(slot) => controller .block_by_slot(slot)? .map(|with_status| with_status.map(|with_status| with_status.root)), diff --git a/runtime/src/grandine_args.rs b/runtime/src/grandine_args.rs index cf9e0fad5..2848c9bd2 100644 --- a/runtime/src/grandine_args.rs +++ b/runtime/src/grandine_args.rs @@ -125,10 +125,6 @@ pub struct GrandineArgs { #[clap(long, value_delimiter = ',')] features: Vec, - /// Enable the Fast Confirmation Rule for single-slot block confirmation - #[clap(long)] - fast_confirmation_rule: bool, - #[clap(subcommand)] command: Option, } @@ -486,6 +482,10 @@ struct BeaconNodeOptions { /// [default: disabled] #[clap(long)] sync_without_reconstruction: bool, + + /// Enable the Fast Confirmation Rule for single-slot block confirmation + #[clap(long)] + fast_confirmation_rule: bool, } #[expect( @@ -1018,7 +1018,6 @@ impl GrandineArgs { graffiti, disable_blockprint_graffiti, mut features, - fast_confirmation_rule, command, .. } = self; @@ -1087,6 +1086,7 @@ impl GrandineArgs { kzg_backend, blacklisted_blocks, sync_without_reconstruction, + fast_confirmation_rule, .. } = beacon_node_options; diff --git a/scripts/download_spec_tests.sh b/scripts/download_spec_tests.sh index ededa3e7b..71cffd6a9 100755 --- a/scripts/download_spec_tests.sh +++ b/scripts/download_spec_tests.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -SPEC_VERSION="${SPEC_VERSION:-v1.7.0-alpha.4}" +SPEC_VERSION="${SPEC_VERSION:-v1.7.0-alpha.5}" TESTS_DIR="consensus-spec-tests" VERSION_FILE="${TESTS_DIR}/.version" BASE_URL="https://github.com/ethereum/consensus-specs/releases/download/${SPEC_VERSION}" diff --git a/types/src/config.rs b/types/src/config.rs index 9ba78d9b2..b7c31453b 100644 --- a/types/src/config.rs +++ b/types/src/config.rs @@ -133,6 +133,10 @@ pub struct Config { #[serde(with = "serde_utils::string_or_native")] pub reorg_parent_weight_threshold: u64, + // Fast Confirmation Rule + #[serde(with = "serde_utils::string_or_native")] + pub confirmation_byzantine_threshold: u64, + // Deposit contract #[serde(with = "serde_utils::string_or_native")] pub deposit_chain_id: ChainId, @@ -275,6 +279,9 @@ impl Default for Config { reorg_max_epochs_since_finalization: 2, reorg_parent_weight_threshold: 160, + // Fast Confirmation Rule + confirmation_byzantine_threshold: 25, + // Deposit contract deposit_chain_id: 0, deposit_contract_address: ExecutionAddress::zero(), From 212128730d1824c25efca9a0613ab75a710c5c17 Mon Sep 17 00:00:00 2001 From: bomanaps Date: Sun, 24 May 2026 03:07:53 +0100 Subject: [PATCH 5/7] FCR: spec-align (head-state committees, pulled-up FFG balance source, single-writer confirmed_root) + fast_confirmation SSE event (beacon-APIs PR #598) --- Cargo.lock | 2 + fork_choice_control/src/events.rs | 37 +++++ fork_choice_control/src/helpers.rs | 15 +-- fork_choice_control/src/mutator.rs | 26 ++-- fork_choice_control/src/spec_tests.rs | 17 ++- fork_choice_store/Cargo.toml | 2 + .../src/fast_confirmation/committees.rs | 106 +++++++++++++++ .../src/fast_confirmation/mod.rs | 2 + .../src/fast_confirmation/store.rs | 104 +++++++++------ fork_choice_store/src/store.rs | 126 +++++++----------- http_api/src/standard.rs | 1 + 11 files changed, 285 insertions(+), 153 deletions(-) create mode 100644 fork_choice_store/src/fast_confirmation/committees.rs diff --git a/Cargo.lock b/Cargo.lock index 95d853ec1..e5545ba3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5997,6 +5997,7 @@ dependencies = [ "features", "futures", "hash_hasher", + "hashing", "helper_functions", "im", "itertools 0.14.0", @@ -6006,6 +6007,7 @@ dependencies = [ "pubkey_cache", "scc 3.7.0", "serde", + "shuffling", "ssz", "state_cache", "static_assertions", diff --git a/fork_choice_control/src/events.rs b/fork_choice_control/src/events.rs index 5ebc91d71..d1a9fbb8b 100644 --- a/fork_choice_control/src/events.rs +++ b/fork_choice_control/src/events.rs @@ -53,6 +53,7 @@ pub enum Topic { ContributionAndProof, DataColumnSidecar, ExecutionPayloadBid, + FastConfirmation, FinalizedCheckpoint, Head, PayloadAttributes, @@ -73,6 +74,7 @@ pub enum Event { ContributionAndProof(Box>), DataColumnSidecar(DataColumnSidecarEvent

), ExecutionPayloadBid(ExecutionPayloadBidEvent

), + FastConfirmation(FastConfirmationEvent), FinalizedCheckpoint(FinalizedCheckpointEvent), Head(HeadEvent), PayloadAttributes(PayloadAttributesEvent), @@ -95,6 +97,7 @@ impl Event

{ Self::ContributionAndProof(_) => Topic::ContributionAndProof, Self::DataColumnSidecar(_) => Topic::DataColumnSidecar, Self::ExecutionPayloadBid(_) => Topic::ExecutionPayloadBid, + Self::FastConfirmation(_) => Topic::FastConfirmation, Self::FinalizedCheckpoint(_) => Topic::FinalizedCheckpoint, Self::Head(_) => Topic::Head, Self::PayloadAttributes(_) => Topic::PayloadAttributes, @@ -118,6 +121,7 @@ pub struct EventChannels { pub contribution_and_proofs: Sender>, pub data_column_sidecars: Sender>, pub execution_payload_bids: Sender>, + pub fast_confirmations: Sender>, pub finalized_checkpoints: Sender>, pub heads: Sender>, pub payload_attributes: Sender>, @@ -148,6 +152,7 @@ impl EventChannels

{ contribution_and_proofs: broadcast::channel(max_events).0, data_column_sidecars: broadcast::channel(max_events).0, execution_payload_bids: broadcast::channel(max_events).0, + fast_confirmations: broadcast::channel(max_events).0, finalized_checkpoints: broadcast::channel(max_events).0, heads: broadcast::channel(max_events).0, payload_attributes: broadcast::channel(max_events).0, @@ -171,6 +176,7 @@ impl EventChannels

{ Topic::ContributionAndProof => &self.contribution_and_proofs, Topic::DataColumnSidecar => &self.data_column_sidecars, Topic::ExecutionPayloadBid => &self.execution_payload_bids, + Topic::FastConfirmation => &self.fast_confirmations, Topic::FinalizedCheckpoint => &self.finalized_checkpoints, Topic::Head => &self.heads, Topic::PayloadAttributes => &self.payload_attributes, @@ -275,6 +281,12 @@ impl EventChannels

{ } } + pub fn send_fast_confirmation_event(&self, block: H256, slot: Slot) { + if let Err(error) = self.send_fast_confirmation_event_internal(block, slot) { + warn_with_peers!("unable to send fast confirmation event: {error}"); + } + } + pub fn send_finalized_checkpoint_event( &self, block_root: H256, @@ -504,6 +516,15 @@ impl EventChannels

{ Ok(()) } + fn send_fast_confirmation_event_internal(&self, block: H256, slot: Slot) -> Result<()> { + if self.fast_confirmations.receiver_count() > 0 { + let event = Event::FastConfirmation(FastConfirmationEvent { block, slot }); + self.fast_confirmations.send(event)?; + } + + Ok(()) + } + fn send_finalized_checkpoint_event_internal( &self, block_root: H256, @@ -655,6 +676,22 @@ pub struct BlockGossipEvent { pub block: H256, } +/// SSE event payload for the `fast_confirmation` topic ([beacon-APIs PR #598]). +/// +/// Emitted from `mutator.rs` after `FastConfirmationStore::on_fast_confirmation` runs and the +/// confirmed root changes (matches Nimbus PR #8479's `if curr == prev: return` emit gate). +/// +/// JSON wire form per the spec: `{"block": "0x...", "slot": "N"}`. The Rust field is named +/// `block` directly (not `block_root`) so no `#[serde(rename)]` is needed. +/// +/// [beacon-APIs PR #598]: https://github.com/ethereum/beacon-APIs/pull/598 +#[derive(Clone, Copy, Debug, Serialize)] +pub struct FastConfirmationEvent { + pub block: H256, + #[serde(with = "serde_utils::string_or_native")] + pub slot: Slot, +} + #[derive(Clone, Debug, Serialize)] pub struct DataColumnSidecarEvent { pub block_root: H256, diff --git a/fork_choice_control/src/helpers.rs b/fork_choice_control/src/helpers.rs index 941134b72..cccc9de72 100644 --- a/fork_choice_control/src/helpers.rs +++ b/fork_choice_control/src/helpers.rs @@ -606,10 +606,7 @@ impl Context

{ assert_eq!(self.controller().proposer_boost_root(), expected_root); } - pub fn assert_fcr_previous_epoch_observed_justified_checkpoint( - &self, - expected: Checkpoint, - ) { + pub fn assert_fcr_previous_epoch_observed_justified_checkpoint(&self, expected: Checkpoint) { assert_eq!( self.controller() .fcr_previous_epoch_observed_justified_checkpoint(), @@ -617,10 +614,7 @@ impl Context

{ ); } - pub fn assert_fcr_current_epoch_observed_justified_checkpoint( - &self, - expected: Checkpoint, - ) { + pub fn assert_fcr_current_epoch_observed_justified_checkpoint(&self, expected: Checkpoint) { assert_eq!( self.controller() .fcr_current_epoch_observed_justified_checkpoint(), @@ -628,10 +622,7 @@ impl Context

{ ); } - pub fn assert_fcr_previous_epoch_greatest_unrealized_checkpoint( - &self, - expected: Checkpoint, - ) { + pub fn assert_fcr_previous_epoch_greatest_unrealized_checkpoint(&self, expected: Checkpoint) { assert_eq!( self.controller() .fcr_previous_epoch_greatest_unrealized_checkpoint(), diff --git a/fork_choice_control/src/mutator.rs b/fork_choice_control/src/mutator.rs index 1274d940f..9472f6cd6 100644 --- a/fork_choice_control/src/mutator.rs +++ b/fork_choice_control/src/mutator.rs @@ -511,10 +511,24 @@ where // FCR: run on_fast_confirmation once per slot, after past-slot attestations have been // applied by `apply_tick`. Spec: `update_fast_confirmation_variables` MUST be called // only once per slot; `is_slot_updated()` suppresses intra-slot tick updates. + // + // Emits the `fast_confirmation` SSE event (beacon-APIs PR #598) when the confirmed + // root changes — matches Nimbus PR #8479's `if curr == prev: return` emit gate. if changes.is_slot_updated() && let Some(fcr) = self.fcr_store.as_mut() { + let previous_confirmed = fcr.confirmed_root(); fcr.on_fast_confirmation(&self.store); + let current_confirmed = fcr.confirmed_root(); + if current_confirmed != previous_confirmed { + let slot = self + .store + .chain_link(current_confirmed) + .map(ChainLink::slot) + .unwrap_or(0); + self.event_channels + .send_fast_confirmation_event(current_confirmed, slot); + } } self.spawn_state_cache_prune_task( @@ -2490,18 +2504,6 @@ where .apply_attester_slashing(slashable_indices)?; } - // FCR: advance confirmed root when the block caused a head change. Spec permits - // `get_latest_confirmed` to be called any number of times per slot; only - // `update_fast_confirmation_variables` is once-per-slot, and that runs in `handle_tick`. - if matches!( - changes, - ApplyBlockChanges::CanonicalChainExtended { .. } - | ApplyBlockChanges::Reorganized { .. } - ) && let Some(fcr) = self.fcr_store.as_mut() - { - fcr.on_head_change(&self.store); - } - // The snapshot should be updated: // - After calling `Mutator::archive_finalized` because it mutates the store. // - Before spawning tasks to retry delayed objects or notifying other components to ensure diff --git a/fork_choice_control/src/spec_tests.rs b/fork_choice_control/src/spec_tests.rs index 42bf0396b..e86f157db 100644 --- a/fork_choice_control/src/spec_tests.rs +++ b/fork_choice_control/src/spec_tests.rs @@ -25,10 +25,7 @@ use types::{ primitives::{H256, Slot, UnixSeconds}, }, preset::{Mainnet, Minimal, Preset}, - traits::{ - BeaconState as _, BlockBodyWithBlobKzgCommitments, BlockBodyWithExecutionPayload as _, - ExecutionPayload as _, SignedBeaconBlock as _, - }, + traits::{BeaconState as _, BlockBodyWithBlobKzgCommitments, SignedBeaconBlock as _}, }; use crate::helpers::Context; @@ -324,11 +321,13 @@ async fn run_case(config: &Arc, case: Case<'_>, fast_confirma // we promote the payload to VALID here so the FCR check logic can see the // same world the pyspec does. Gated on `fast_confirmation_rule` so this does // not affect existing fork_choice tests. - if fast_confirmation_rule && valid && let Some(payload) = block - .message() - .body() - .with_execution_payload() - .map(|body| body.execution_payload()) + if fast_confirmation_rule + && valid + && let Some(payload) = block + .message() + .body() + .with_execution_payload() + .map(types::traits::BlockBodyWithExecutionPayload::execution_payload) { let payload_status = PayloadStatusV1 { status: PayloadValidationStatus::Valid, diff --git a/fork_choice_store/Cargo.toml b/fork_choice_store/Cargo.toml index f5aac1b3a..91c677458 100644 --- a/fork_choice_store/Cargo.toml +++ b/fork_choice_store/Cargo.toml @@ -21,6 +21,7 @@ execution_engine = { workspace = true } features = { workspace = true } futures = { workspace = true } hash_hasher = { workspace = true } +hashing = { workspace = true } helper_functions = { workspace = true } im = { workspace = true } itertools = { workspace = true } @@ -30,6 +31,7 @@ prometheus_metrics = { workspace = true } pubkey_cache = { workspace = true } scc = { workspace = true } serde = { workspace = true } +shuffling = { workspace = true } ssz = { workspace = true } state_cache = { workspace = true } static_assertions = { workspace = true } diff --git a/fork_choice_store/src/fast_confirmation/committees.rs b/fork_choice_store/src/fast_confirmation/committees.rs new file mode 100644 index 000000000..6344e5da3 --- /dev/null +++ b/fork_choice_store/src/fast_confirmation/committees.rs @@ -0,0 +1,106 @@ +//! FCR-local committee accessor. +//! +//! The Fast Confirmation Rule reconfirmation path can query committees for slots whose epoch +//! is `current_epoch − 2` (spec note on [`compute_empty_slot_support_discount`] and the spec +//! requirement on [`get_slot_committee`] — "MUST support committees of epochs starting from +//! `current_epoch − 2`"). Grandine's `helper_functions::accessors::beacon_committee` routes +//! through `relative_epoch`, which only resolves `Next` / `Current` / `Previous` (i.e. a +//! ±1-epoch window from the state's current epoch) and returns `EpochBeforePrevious` for +//! anything older, silently zeroing the equivocation score / empty-slot discount in the FCR +//! helpers when the lookup reaches `current_epoch − 2`. +//! +//! This helper computes the slot's committee participants directly from `state.randao_mixes`, +//! mirroring pyspec's [`get_beacon_committee`] semantics, so any epoch whose RANDAO mix is +//! still inside the `EPOCHS_PER_HISTORICAL_VECTOR` ring is computable. Scope: the function is +//! used only by `Store::fcr_get_equivocation_score` and `Store::fcr_precompute_support_discount`; +//! the existing `relative_epoch` and `beacon_committee` accessors are not modified. +//! +//! [`get_slot_committee`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#get_slot_committee +//! [`compute_empty_slot_support_discount`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#compute_empty_slot_support_discount +//! [`get_beacon_committee`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/beacon-chain.md#get_beacon_committee + +use std::collections::HashSet as StdHashSet; + +use helper_functions::{accessors, misc, predicates}; +use typenum::Unsigned as _; +use types::{ + combined::BeaconState, + phase0::{ + consts::DOMAIN_BEACON_ATTESTER, + primitives::{Slot, ValidatorIndex}, + }, + preset::Preset, + traits::BeaconState as _, +}; + +/// Returns the union of validator indices assigned to all committees at `slot`. +/// +/// Tries the cached `accessors::beacon_committees` path first (the same accessor every +/// non-FCR caller uses). When that path rejects the lookup with `EpochBeforePrevious` +/// (i.e. the queried slot's epoch is more than `MIN_SEED_LOOKAHEAD + 1` behind the state's +/// current epoch), falls back to a direct shuffling derived from `state.randao_mixes` — +/// mirroring the pyspec sequence `get_active_validator_indices → get_seed → shuffle → +/// slice`. The fallback is only invoked on the FCR reconfirmation path, where the spec +/// requires committees for slots up to `current_epoch − 2`. +pub fn fcr_slot_committee_participants( + state: &BeaconState

, + slot: Slot, +) -> StdHashSet { + if let Ok(committees) = accessors::beacon_committees::

(state, slot) { + let mut participants = StdHashSet::new(); + for committee in committees { + for vi in committee { + participants.insert(vi); + } + } + return participants; + } + + let epoch = misc::compute_epoch_at_slot::

(slot); + + let mut active_indices: Vec = state + .validators() + .into_iter() + .enumerate() + .filter_map(|(i, validator)| { + if predicates::is_active_validator(validator, epoch) { + ValidatorIndex::try_from(i).ok() + } else { + None + } + }) + .collect(); + + if active_indices.is_empty() { + return StdHashSet::new(); + } + + // spec `get_seed(state, epoch, DOMAIN_BEACON_ATTESTER)`: + // mix = state.randao_mixes[(epoch + EPOCHS_PER_HISTORICAL_VECTOR - MIN_SEED_LOOKAHEAD - 1) + // % EPOCHS_PER_HISTORICAL_VECTOR] + // seed = hash(DOMAIN_BEACON_ATTESTER || epoch || mix) + let mix_epoch = epoch + P::EpochsPerHistoricalVector::U64 - P::MinSeedLookahead::U64 - 1; + let mix = accessors::get_randao_mix(state, mix_epoch); + let seed = hashing::hash_32_64_256(DOMAIN_BEACON_ATTESTER.to_fixed_bytes(), epoch, mix); + + shuffling::shuffle_slice::(&mut active_indices, seed) + .expect("active_indices length fits in u64 by Preset's ValidatorRegistryLimit bound"); + + // spec `get_beacon_committee(state, slot, index)` slices the shuffled array by + // start = validator_count * index_in_epoch / committees_in_epoch + // end = validator_count * (index_in_epoch + 1) / committees_in_epoch + // Iterating every committee at this slot and unioning the slices is equivalent to a + // single contiguous range, because committees at a given slot occupy adjacent + // sub-ranges of the shuffled list: + // committees_per_slot * slot_in_epoch // committees_in_epoch + // = slot_in_epoch / SLOTS_PER_EPOCH (with the validator_count multiplier). + let validator_count = active_indices.len(); + let spe = usize::try_from(P::SlotsPerEpoch::U64) + .expect("SLOTS_PER_EPOCH fits in usize on any supported target"); + let slot_in_epoch = usize::try_from(misc::slots_since_epoch_start::

(slot)) + .expect("slot-in-epoch is bounded by SLOTS_PER_EPOCH which fits in usize"); + let start = validator_count * slot_in_epoch / spe; + let end = validator_count * (slot_in_epoch + 1) / spe; + + active_indices[start..end].iter().copied().collect() +} diff --git a/fork_choice_store/src/fast_confirmation/mod.rs b/fork_choice_store/src/fast_confirmation/mod.rs index ebb580a1c..19dc45d3b 100644 --- a/fork_choice_store/src/fast_confirmation/mod.rs +++ b/fork_choice_store/src/fast_confirmation/mod.rs @@ -6,6 +6,7 @@ //! //! Spec: +mod committees; mod rules; mod store; mod types; @@ -13,6 +14,7 @@ mod types; // `pub` here is equivalent to `pub(crate)` because `mod fast_confirmation` is crate-private // at the crate root (see `lib.rs`). `Store::fcr_build_chain_info` and `fcr_build_ffg_data` // reach these via the `fast_confirmation::NAME` shortcut; external crates never see them. +pub use self::committees::fcr_slot_committee_participants; pub use self::rules::{ compute_adversarial_weight, compute_proposer_score, estimate_committee_weight_between_slots, get_voting_source_epoch, diff --git a/fork_choice_store/src/fast_confirmation/store.rs b/fork_choice_store/src/fast_confirmation/store.rs index 0c55d9fdd..f1ea657fb 100644 --- a/fork_choice_store/src/fast_confirmation/store.rs +++ b/fork_choice_store/src/fast_confirmation/store.rs @@ -60,6 +60,35 @@ fn fcr_state_at_checkpoint>( Some(state) } +/// Returns the head state advanced via `process_slots` to the first slot of the store's +/// current epoch when the head state is behind; otherwise returns the head state unchanged. +/// +/// Mirrors the spec's [`get_pulled_up_head_state`]. The spec uses this as the balance source +/// for the FFG helpers (`get_current_target_score`, +/// `compute_honest_ffg_support_for_current_target`, `will_no_conflicting_checkpoint_be_justified`, +/// `will_current_target_be_justified`). +/// +/// [`get_pulled_up_head_state`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#get_pulled_up_head_state +fn get_pulled_up_head_state>( + store: &Store, +) -> Option>> { + let mut state = store.head().state(store); + let current_store_epoch = store.current_epoch(); + let head_state_epoch = misc::compute_epoch_at_slot::

(state.slot()); + if head_state_epoch >= current_store_epoch { + return Some(state); + } + let target_slot = misc::compute_start_slot_at_epoch::

(current_store_epoch); + combined::process_slots( + store.chain_config(), + store.pubkey_cache(), + state.make_mut(), + target_slot, + ) + .ok()?; + Some(state) +} + /// The spec's `FastConfirmationStore`, carrying all state tracked by the Fast Confirmation Rule. /// /// Roughly corresponds to [`FastConfirmationStore`] from the Fast Confirmation specification. @@ -123,14 +152,6 @@ impl FastConfirmationStore

{ self.confirmed_root = self.get_latest_confirmed(store); } - /// Re-runs only `get_latest_confirmed` — called when a new head is selected mid-slot. - /// The spec permits `get_latest_confirmed` to be called at any point in a slot; - /// `update_fast_confirmation_variables` MUST run only once per slot, so this entry point - /// does not invoke it. - pub fn on_head_change>(&mut self, store: &Store) { - self.confirmed_root = self.get_latest_confirmed(store); - } - /// Rotates slot heads, snapshots the greatest-unrealized checkpoint at epoch end, /// and rotates observed justified checkpoints at epoch start. /// @@ -161,10 +182,11 @@ impl FastConfirmationStore

{ self.previous_epoch_greatest_unrealized_checkpoint; } - // Refresh the per-slot FFG support cache. `fcr_state_at_checkpoint` falls back to a lazy - // `state_at_checkpoint` computation when the cached entry is absent. - let curr_obs = self.current_epoch_observed_justified_checkpoint; - let ffg_inputs = fcr_state_at_checkpoint(store, curr_obs).map(|arc| { + // Refresh the per-slot FFG support cache. Spec + // `compute_honest_ffg_support_for_current_target` line 734 sources `total_active_balance` + // and the active-validator set from `get_pulled_up_head_state(store)` — the head state + // advanced to the start of the current store epoch via `process_slots`. + let ffg_inputs = get_pulled_up_head_state(store).map(|arc| { let balances = Store::::active_balances(&arc); let total: Gwei = balances.iter().sum(); (arc, balances, total) @@ -172,12 +194,7 @@ impl FastConfirmationStore

{ if let Some((_state, balances, total)) = ffg_inputs { self.ffg_total_active_balance = total; - let ffg = store.fcr_build_ffg_data( - self.current_epoch_observed_justified_checkpoint, - self.honest_ffg_support, - &balances, - total, - ); + let ffg = store.fcr_build_ffg_data(self.honest_ffg_support, &balances, total); self.honest_ffg_support = compute_honest_ffg_support_for_current_target(&ffg); } else { self.ffg_total_active_balance = 0; @@ -257,7 +274,6 @@ impl FastConfirmationStore

{ self.previous_slot_head, self.confirmed_root, start_root_exclusive, - Some(&prev_state), &prev_balances, prev_total, ); @@ -277,10 +293,7 @@ impl FastConfirmationStore

{ let obs = self.current_epoch_observed_justified_checkpoint; // Use the epoch of the block obs.root points to, not checkpoint.epoch — a checkpoint // may have epoch N while its block is from epoch N-1 (when the epoch-start slot was skipped). - let obs_slot = store - .chain_link(obs.root) - .map(ChainLink::slot) - .unwrap_or(0); + let obs_slot = store.chain_link(obs.root).map(ChainLink::slot).unwrap_or(0); let obs_block_epoch = misc::compute_epoch_at_slot::

(obs_slot); if is_epoch_start && obs_block_epoch + 1 == current_epoch @@ -304,29 +317,34 @@ impl FastConfirmationStore

{ return confirmed_root; } + // LMD-GHOST balance source — spec `get_current_balance_source` (lines 894, 920): + // state at the current-epoch observed justified checkpoint. Used for the chain-info + // `active_balances` / `total_active_balance` passed into `is_one_confirmed`. let curr_state = fcr_state_at_checkpoint(store, self.current_epoch_observed_justified_checkpoint); - let (chain, ffg) = if let Some(state_arc) = curr_state { - let balances = Store::::active_balances(&state_arc); - let total: Gwei = balances.iter().sum(); - let chain = store.fcr_build_chain_info( - self.previous_slot_head, - head.block_root, - confirmed_root, - Some(&state_arc), - &balances, - total, - ); - let ffg = store.fcr_build_ffg_data( - self.current_epoch_observed_justified_checkpoint, - self.honest_ffg_support, - &balances, - total, - ); - (chain, ffg) - } else { - return confirmed_root; + // FFG balance source — spec `get_pulled_up_head_state` (lines 697, 734, 779, 795): + // head state advanced to the start of the current store epoch when behind. + let pulled_up_head = get_pulled_up_head_state(store); + + let (chain, ffg) = match (curr_state, pulled_up_head) { + (Some(balance_state), Some(pulled_up_state)) => { + let balances = Store::::active_balances(&balance_state); + let total: Gwei = balances.iter().sum(); + let chain = store.fcr_build_chain_info( + self.previous_slot_head, + head.block_root, + confirmed_root, + &balances, + total, + ); + let ffg_balances = Store::::active_balances(&pulled_up_state); + let ffg_total: Gwei = ffg_balances.iter().sum(); + let ffg = + store.fcr_build_ffg_data(self.honest_ffg_support, &ffg_balances, ffg_total); + (chain, ffg) + } + _ => return confirmed_root, }; let prev_head_voting_source_epoch = store diff --git a/fork_choice_store/src/store.rs b/fork_choice_store/src/store.rs index c54d161b7..4424bc873 100644 --- a/fork_choice_store/src/store.rs +++ b/fork_choice_store/src/store.rs @@ -1037,7 +1037,7 @@ impl> Store { self.checkpoint_states.get(&checkpoint) } - pub fn pubkey_cache(&self) -> &Arc { + pub const fn pubkey_cache(&self) -> &Arc { &self.pubkey_cache } @@ -4660,24 +4660,24 @@ impl> Store { /// Sums active balances of equivocating validators whose committee assignments fall in `from_slot..=to_slot`. /// /// Roughly corresponds to [`get_equivocation_score`] from the Fast Confirmation specification. + /// Per spec `get_slot_committee` (line 240), committees come from `store.block_states[head]` + /// — the head state — not from the FCR balance source. `fcr_slot_committee_participants` + /// handles both the standard `±1`-epoch range and the `current_epoch − 2` reconfirmation + /// case via its randao-mixes fallback. /// /// [`get_equivocation_score`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#get_equivocation_score fn fcr_get_equivocation_score( &self, - state: &BeaconState

, from_slot: Slot, to_slot: Slot, active_balances: &[Gwei], ) -> Gwei { + let head_state = self.head().state(self); let mut eq_set: StdHashSet = StdHashSet::new(); for s in from_slot..=to_slot { - if let Ok(committees) = accessors::beacon_committees::

(state, s) { - for committee in committees { - for vi in committee { - if self.equivocating_indices.contains(&vi) { - eq_set.insert(vi); - } - } + for vi in fast_confirmation::fcr_slot_committee_participants::

(&head_state, s) { + if self.equivocating_indices.contains(&vi) { + eq_set.insert(vi); } } } @@ -4731,7 +4731,6 @@ impl> Store { parent_slot: Slot, slot: Slot, parent_root: H256, - state: &BeaconState

, active_balances: &[Gwei], total_active_balance: Gwei, ) -> Gwei { @@ -4742,16 +4741,18 @@ impl> Store { let empty_start = parent_slot + 1; let empty_end = slot - 1; - // spec: get_block_support_between_slots for parent in empty slots + // spec `get_block_support_between_slots` collects participants from the head state's + // shuffling (spec line 240: `shuffling_source = store.block_states[head]`), not from + // the balance source. `fcr_slot_committee_participants` mirrors pyspec + // `get_beacon_committee` and additionally serves `current_epoch − 2` lookups via the + // randao-mixes fallback path required by the empty-slot reconfirmation spec note. + let head_state = self.head().state(self); let mut participants: StdHashSet = StdHashSet::new(); for s in empty_start..=empty_end { - if let Ok(committees) = accessors::beacon_committees::

(state, s) { - for committee in committees { - for vi in committee { - participants.insert(vi); - } - } - } + participants.extend(fast_confirmation::fcr_slot_committee_participants::

( + &head_state, + s, + )); } let parent_support_in_empty: Gwei = participants .iter() @@ -4780,7 +4781,7 @@ impl> Store { let empty_equivocation: Gwei = if self.equivocating_indices.is_empty() { 0 } else { - self.fcr_get_equivocation_score(state, empty_start, empty_end, active_balances) + self.fcr_get_equivocation_score(empty_start, empty_end, active_balances) }; let adv_empty = fast_confirmation::compute_adversarial_weight( empty_committee_weight, @@ -4809,7 +4810,6 @@ impl> Store { previous_slot_head: H256, block_root: H256, terminal_root: H256, - balance_source: Option<&BeaconState

>, active_balances: &[Gwei], total_active_balance: Gwei, ) -> Vec { @@ -4925,18 +4925,9 @@ impl> Store { }; // spec: get_equivocation_score for adv_start..=end_slot - let adversarial: Gwei = if current_slot > 0 { - balance_source - .filter(|_| !self.equivocating_indices.is_empty()) - .map(|state| { - self.fcr_get_equivocation_score( - state, - adv_start_slot, - end_slot, - active_balances, - ) - }) - .unwrap_or(0) + let adversarial: Gwei = if current_slot > 0 && !self.equivocating_indices.is_empty() + { + self.fcr_get_equivocation_score(adv_start_slot, end_slot, active_balances) } else { 0 }; @@ -4947,18 +4938,13 @@ impl> Store { ); // spec: compute_empty_slot_support_discount - let support_discount: Gwei = balance_source - .map(|state| { - self.fcr_precompute_support_discount( - parent_slot, - slot, - parent_root, - state, - active_balances, - total_active_balance, - ) - }) - .unwrap_or(0); + let support_discount: Gwei = self.fcr_precompute_support_discount( + parent_slot, + slot, + parent_root, + active_balances, + total_active_balance, + ); Some(fast_confirmation::ChainInfo { block_root: root, @@ -4988,7 +4974,6 @@ impl> Store { /// this helper carries no FCR state of its own. pub(crate) fn fcr_build_ffg_data( &self, - curr_obs_justified: Checkpoint, honest_ffg_support: Gwei, active_balances: &[Gwei], total_active_balance: Gwei, @@ -5049,38 +5034,25 @@ impl> Store { 0 }; - let adversarial_this_epoch: Gwei = if current_slot > 0 - && !self.equivocating_indices.is_empty() - { - match self - .checkpoint_states - .get(&curr_obs_justified) - .map(AsRef::as_ref) - { - Some(state) => { - let adv_weight = fast_confirmation::estimate_committee_weight_between_slots::

( - total_active_balance, - epoch_start, - current_slot - 1, - ); - let max_adversarial = - adv_weight / 100 * self.chain_config.confirmation_byzantine_threshold; - let eq_score = self.fcr_get_equivocation_score( - state, - epoch_start, - current_slot - 1, - active_balances, - ); - max_adversarial.saturating_sub(eq_score) - } - None => { - debug!( - checkpoint = ?curr_obs_justified, - "FCR: no checkpoint state for curr_obs_justified, equivocation penalty zeroed" - ); - 0 - } - } + // spec `compute_adversarial_weight(store, balance_source, epoch_start, current_slot − 1)`: + // always returns `max_adversarial − equivocation_score` (saturated at 0), regardless of + // whether any validators have equivocated. The equivocation lookup goes through the + // head-state-based committee accessor (spec `get_slot_committee` uses + // `store.block_states[head]`). + let adversarial_this_epoch: Gwei = if current_slot > 0 { + let adv_weight = fast_confirmation::estimate_committee_weight_between_slots::

( + total_active_balance, + epoch_start, + current_slot - 1, + ); + let max_adversarial = + adv_weight / 100 * self.chain_config.confirmation_byzantine_threshold; + let eq_score = if self.equivocating_indices.is_empty() { + 0 + } else { + self.fcr_get_equivocation_score(epoch_start, current_slot - 1, active_balances) + }; + max_adversarial.saturating_sub(eq_score) } else { 0 }; diff --git a/http_api/src/standard.rs b/http_api/src/standard.rs index 775d6beee..5e90e675f 100644 --- a/http_api/src/standard.rs +++ b/http_api/src/standard.rs @@ -2501,6 +2501,7 @@ pub async fn beacon_events( Event::ChainReorg(data) => ssevent.json_data(data), Event::ContributionAndProof(data) => ssevent.json_data(data), Event::DataColumnSidecar(data) => ssevent.json_data(data), + Event::FastConfirmation(data) => ssevent.json_data(data), Event::FinalizedCheckpoint(data) => ssevent.json_data(data), Event::Head(data) => ssevent.json_data(data), Event::PayloadAttributes(data) => ssevent.json_data(data), From bc6a6688d405ef740b077d63f32100f8401e750a Mon Sep 17 00:00:00 2001 From: bomanaps Date: Fri, 5 Jun 2026 01:28:40 +0100 Subject: [PATCH 6/7] add metrics align spec alpha 9 --- Cargo.toml | 2 +- fork_choice_control/src/controller.rs | 3 + fork_choice_control/src/mutator.rs | 51 ++++++++++++++-- fork_choice_control/src/queries.rs | 2 +- fork_choice_control/src/spec_tests.rs | 2 +- .../src/fast_confirmation/committees.rs | 6 +- .../src/fast_confirmation/mod.rs | 2 +- .../src/fast_confirmation/rules.rs | 24 ++++---- .../src/fast_confirmation/store.rs | 23 ++++---- fork_choice_store/src/store.rs | 8 +-- prometheus_metrics/src/metrics.rs | 58 +++++++++++++++++++ scripts/download_spec_tests.sh | 2 +- 12 files changed, 141 insertions(+), 42 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b0317b0f6..fa739ad8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,7 +100,7 @@ unsafe_code = 'forbid' absolute_paths_not_starting_with_crate = 'warn' anonymous_parameters = 'warn' deprecated_in_future = 'warn' -deprecated_safe = 'warn' +deprecated_safe = { level = 'warn', priority = -1 } let_underscore_drop = 'warn' macro_use_extern_crate = 'warn' meta_variable_misuse = 'warn' diff --git a/fork_choice_control/src/controller.rs b/fork_choice_control/src/controller.rs index ddecebe57..0016f7295 100644 --- a/fork_choice_control/src/controller.rs +++ b/fork_choice_control/src/controller.rs @@ -156,6 +156,9 @@ where .store_config() .fast_confirmation_rule .then(|| FastConfirmationStore::new(&store)); + if let Some(metrics) = metrics.as_ref() { + metrics.set_beacon_fast_confirmation_enabled(fcr_store.is_some()); + } let fcr_snapshot = Arc::new(ArcSwap::from_pointee(fcr_store)); let state_cache = store.state_cache(); diff --git a/fork_choice_control/src/mutator.rs b/fork_choice_control/src/mutator.rs index 9472f6cd6..f7f4d6a9e 100644 --- a/fork_choice_control/src/mutator.rs +++ b/fork_choice_control/src/mutator.rs @@ -514,20 +514,59 @@ where // // Emits the `fast_confirmation` SSE event (beacon-APIs PR #598) when the confirmed // root changes — matches Nimbus PR #8479's `if curr == prev: return` emit gate. + // Updates Prometheus FCR metrics each tick (gauges always-fresh; counters only on change). if changes.is_slot_updated() && let Some(fcr) = self.fcr_store.as_mut() { let previous_confirmed = fcr.confirmed_root(); + let previous_slot = self + .store + .chain_link(previous_confirmed) + .map(ChainLink::slot) + .unwrap_or(0); + + // Histogram timer is dropped when this scope ends, recording the FCR pass duration. + let timer = self + .metrics + .as_ref() + .map(|m| m.beacon_fast_confirmation_duration_seconds.start_timer()); fcr.on_fast_confirmation(&self.store); + drop(timer); + let current_confirmed = fcr.confirmed_root(); + let current_confirmed_slot = self + .store + .chain_link(current_confirmed) + .map(ChainLink::slot) + .unwrap_or(0); + if current_confirmed != previous_confirmed { - let slot = self - .store - .chain_link(current_confirmed) - .map(ChainLink::slot) - .unwrap_or(0); self.event_channels - .send_fast_confirmation_event(current_confirmed, slot); + .send_fast_confirmation_event(current_confirmed, current_confirmed_slot); + } + + if let Some(metrics) = self.metrics.as_ref() { + let store_slot = self.store.slot(); + metrics.set_beacon_fast_confirmation_confirmed_slot(current_confirmed_slot); + metrics.set_beacon_fast_confirmation_confirmed_lag_slots( + store_slot.saturating_sub(current_confirmed_slot), + ); + + if current_confirmed != previous_confirmed { + // Advance = new confirmed is a descendant of the previous confirmed. Spec's + // `find_latest_confirmed_descendant` Loop 1 and restart-to-GU both produce + // descendants on the canonical chain. Anything else (reset to finalized, or + // reset+advance compound after a chain reorg) signals broken synchrony. + let is_advance = self + .store + .ancestor(current_confirmed, previous_slot) + .is_some_and(|anc| anc == previous_confirmed); + if is_advance { + metrics.beacon_fast_confirmation_advances_total.inc(); + } else { + metrics.beacon_fast_confirmation_resets_total.inc(); + } + } } } diff --git a/fork_choice_control/src/queries.rs b/fork_choice_control/src/queries.rs index 5f9377125..19cad61a2 100644 --- a/fork_choice_control/src/queries.rs +++ b/fork_choice_control/src/queries.rs @@ -1273,7 +1273,7 @@ impl Snapshot<'_, P> { /// When FCR is enabled, resolves the FCR-confirmed block via `FastConfirmationStore`. /// Otherwise falls back to the justified block's execution payload hash. /// - /// Related: + /// Related: #[must_use] pub fn safe_execution_payload_hash(&self) -> ExecutionBlockHash { let store = &*self.store_snapshot; diff --git a/fork_choice_control/src/spec_tests.rs b/fork_choice_control/src/spec_tests.rs index e86f157db..2f9ccba6a 100644 --- a/fork_choice_control/src/spec_tests.rs +++ b/fork_choice_control/src/spec_tests.rs @@ -171,7 +171,7 @@ fn function_name(case: Case<'_>) { }); } -// Fast Confirmation Rule spec-test vectors (added in `consensus-specs` v1.7.0-alpha.5). +// Fast Confirmation Rule spec-test vectors (added in `consensus-specs` v1.7.0-alpha.9). // Per the release layout, FCR vectors exist only under `tests/minimal//fast_confirmation/`; // mainnet presets are not generated. See `tests/formats/fast_confirmation/README.md`. #[duplicate_item( diff --git a/fork_choice_store/src/fast_confirmation/committees.rs b/fork_choice_store/src/fast_confirmation/committees.rs index 6344e5da3..bba82253a 100644 --- a/fork_choice_store/src/fast_confirmation/committees.rs +++ b/fork_choice_store/src/fast_confirmation/committees.rs @@ -15,9 +15,9 @@ //! used only by `Store::fcr_get_equivocation_score` and `Store::fcr_precompute_support_discount`; //! the existing `relative_epoch` and `beacon_committee` accessors are not modified. //! -//! [`get_slot_committee`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#get_slot_committee -//! [`compute_empty_slot_support_discount`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#compute_empty_slot_support_discount -//! [`get_beacon_committee`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/beacon-chain.md#get_beacon_committee +//! [`get_slot_committee`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#get_slot_committee +//! [`compute_empty_slot_support_discount`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#compute_empty_slot_support_discount +//! [`get_beacon_committee`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/beacon-chain.md#get_beacon_committee use std::collections::HashSet as StdHashSet; diff --git a/fork_choice_store/src/fast_confirmation/mod.rs b/fork_choice_store/src/fast_confirmation/mod.rs index 19dc45d3b..42b6a8ce6 100644 --- a/fork_choice_store/src/fast_confirmation/mod.rs +++ b/fork_choice_store/src/fast_confirmation/mod.rs @@ -4,7 +4,7 @@ //! are free functions in [`rules`](self::rules) operating on the pre-computed types defined in //! [`types`](self::types). //! -//! Spec: +//! Spec: mod committees; mod rules; diff --git a/fork_choice_store/src/fast_confirmation/rules.rs b/fork_choice_store/src/fast_confirmation/rules.rs index 30c856c7c..0de134a3b 100644 --- a/fork_choice_store/src/fast_confirmation/rules.rs +++ b/fork_choice_store/src/fast_confirmation/rules.rs @@ -18,7 +18,7 @@ use super::types::{COMMITTEE_WEIGHT_ESTIMATION_ADJUSTMENT_FACTOR, ChainInfo, Fcr /// /// Roughly corresponds to [`estimate_committee_weight_between_slots`] from the Fast Confirmation specification. /// -/// [`estimate_committee_weight_between_slots`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#estimate_committee_weight_between_slots +/// [`estimate_committee_weight_between_slots`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#estimate_committee_weight_between_slots pub fn estimate_committee_weight_between_slots( total_active_balance: Gwei, start_slot: Slot, @@ -65,7 +65,7 @@ pub fn estimate_committee_weight_between_slots( /// /// Roughly corresponds to [`compute_proposer_score`] from the Fork Choice specification. /// -/// [`compute_proposer_score`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fork-choice.md#compute_proposer_score +/// [`compute_proposer_score`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fork-choice.md#compute_proposer_score pub fn compute_proposer_score( total_active_balance: Gwei, proposer_score_boost: u64, @@ -78,7 +78,7 @@ pub fn compute_proposer_score( /// /// Roughly corresponds to [`compute_adversarial_weight`] from the Fast Confirmation specification. /// -/// [`compute_adversarial_weight`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#compute_adversarial_weight +/// [`compute_adversarial_weight`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#compute_adversarial_weight pub const fn compute_adversarial_weight( adv_committee_weight: Gwei, equivocation_score: Gwei, @@ -92,7 +92,7 @@ pub const fn compute_adversarial_weight( /// /// Roughly corresponds to [`compute_empty_slot_support_discount`] from the Fast Confirmation specification. /// -/// [`compute_empty_slot_support_discount`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#compute_empty_slot_support_discount +/// [`compute_empty_slot_support_discount`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#compute_empty_slot_support_discount #[inline] pub const fn compute_empty_slot_support_discount(info: &ChainInfo) -> Gwei { info.support_discount @@ -102,7 +102,7 @@ pub const fn compute_empty_slot_support_discount(info: &ChainInfo) -> Gwei { /// /// Roughly corresponds to [`compute_safety_threshold`] from the Fast Confirmation specification. /// -/// [`compute_safety_threshold`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#compute_safety_threshold +/// [`compute_safety_threshold`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#compute_safety_threshold pub const fn compute_safety_threshold(info: &ChainInfo) -> Gwei { let adversarial_weight = compute_adversarial_weight( info.adv_committee_weight, @@ -127,7 +127,7 @@ pub const fn compute_safety_threshold(info: &ChainInfo) -> Gwei { /// /// Roughly corresponds to [`is_one_confirmed`] from the Fast Confirmation specification. /// -/// [`is_one_confirmed`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#is_one_confirmed +/// [`is_one_confirmed`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#is_one_confirmed pub const fn is_one_confirmed(info: &ChainInfo) -> bool { if !info.is_valid { return false; @@ -139,7 +139,7 @@ pub const fn is_one_confirmed(info: &ChainInfo) -> bool { /// /// Roughly corresponds to [`get_voting_source`] from the Fork Choice specification. /// -/// [`get_voting_source`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fork-choice.md#get_voting_source +/// [`get_voting_source`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fork-choice.md#get_voting_source pub const fn get_voting_source_epoch( block_epoch: Epoch, current_epoch: Epoch, @@ -157,7 +157,7 @@ pub const fn get_voting_source_epoch( /// /// Roughly corresponds to [`compute_honest_ffg_support_for_current_target`] from the Fast Confirmation specification. /// -/// [`compute_honest_ffg_support_for_current_target`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#compute_honest_ffg_support_for_current_target +/// [`compute_honest_ffg_support_for_current_target`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#compute_honest_ffg_support_for_current_target pub fn compute_honest_ffg_support_for_current_target(ffg: &FcrFfgData) -> Gwei { let remaining_ffg_weight = ffg .total_active_balance @@ -176,7 +176,7 @@ pub fn compute_honest_ffg_support_for_current_target(ffg: &FcrFfgData) -> Gwei { /// /// Roughly corresponds to [`will_no_conflicting_checkpoint_be_justified`] from the Fast Confirmation specification. /// -/// [`will_no_conflicting_checkpoint_be_justified`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#will_no_conflicting_checkpoint_be_justified +/// [`will_no_conflicting_checkpoint_be_justified`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#will_no_conflicting_checkpoint_be_justified pub fn will_no_conflicting_checkpoint_be_justified(ffg: &FcrFfgData) -> bool { // Spec shortcut: if current target IS the unrealized justified, no conflict is possible if let Some(current_target) = ffg.current_target @@ -195,7 +195,7 @@ pub fn will_no_conflicting_checkpoint_be_justified(ffg: &FcrFfgData) -> bool { /// /// Roughly corresponds to [`will_current_target_be_justified`] from the Fast Confirmation specification. /// -/// [`will_current_target_be_justified`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#will_current_target_be_justified +/// [`will_current_target_be_justified`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#will_current_target_be_justified pub const fn will_current_target_be_justified(ffg: &FcrFfgData) -> bool { if ffg.total_active_balance == 0 { return false; @@ -207,7 +207,7 @@ pub const fn will_current_target_be_justified(ffg: &FcrFfgData) -> bool { /// /// Roughly corresponds to [`is_confirmed_chain_safe`] from the Fast Confirmation specification. /// -/// [`is_confirmed_chain_safe`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#is_confirmed_chain_safe +/// [`is_confirmed_chain_safe`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#is_confirmed_chain_safe pub fn is_confirmed_chain_safe(chain: &[ChainInfo]) -> bool { chain.iter().all(is_one_confirmed) } @@ -224,7 +224,7 @@ pub fn is_confirmed_chain_safe(chain: &[ChainInfo]) -> bool { /// - `prev_head_unrealized_justified_epoch`: unrealized justified epoch of `previous_slot_head`. /// - `head_unrealized_justified_epoch`: unrealized justified epoch of the current head. /// -/// [`find_latest_confirmed_descendant`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#find_latest_confirmed_descendant +/// [`find_latest_confirmed_descendant`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#find_latest_confirmed_descendant #[expect( clippy::too_many_arguments, reason = "This is a pure-math helper translated directly from the spec. Bundling the scalar \ diff --git a/fork_choice_store/src/fast_confirmation/store.rs b/fork_choice_store/src/fast_confirmation/store.rs index f1ea657fb..376946338 100644 --- a/fork_choice_store/src/fast_confirmation/store.rs +++ b/fork_choice_store/src/fast_confirmation/store.rs @@ -5,7 +5,7 @@ //! choice `Store`. In this implementation the `store` reference is passed to methods instead //! of being held as a field (a trivial rearrangement with the same semantics). //! -//! [`FastConfirmationStore`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#fastconfirmationstore +//! [`FastConfirmationStore`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#fastconfirmationstore use core::marker::PhantomData; use std::sync::Arc; @@ -68,7 +68,7 @@ fn fcr_state_at_checkpoint>( /// `compute_honest_ffg_support_for_current_target`, `will_no_conflicting_checkpoint_be_justified`, /// `will_current_target_be_justified`). /// -/// [`get_pulled_up_head_state`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#get_pulled_up_head_state +/// [`get_pulled_up_head_state`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#get_pulled_up_head_state fn get_pulled_up_head_state>( store: &Store, ) -> Option>> { @@ -93,7 +93,7 @@ fn get_pulled_up_head_state>( /// /// Roughly corresponds to [`FastConfirmationStore`] from the Fast Confirmation specification. /// -/// [`FastConfirmationStore`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#fastconfirmationstore +/// [`FastConfirmationStore`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#fastconfirmationstore #[derive(Debug, Clone)] pub struct FastConfirmationStore { /// Root of the most recent confirmed block. @@ -125,7 +125,7 @@ impl FastConfirmationStore

{ /// /// Roughly corresponds to [`get_fast_confirmation_store`] from the Fast Confirmation specification. /// - /// [`get_fast_confirmation_store`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#get_fast_confirmation_store + /// [`get_fast_confirmation_store`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#get_fast_confirmation_store #[must_use] pub const fn new>(store: &Store) -> Self { let finalized = store.finalized_checkpoint(); @@ -146,7 +146,7 @@ impl FastConfirmationStore

{ /// /// Roughly corresponds to [`on_fast_confirmation`] from the Fast Confirmation specification. /// - /// [`on_fast_confirmation`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#on_fast_confirmation + /// [`on_fast_confirmation`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#on_fast_confirmation pub fn on_fast_confirmation>(&mut self, store: &Store) { self.update_variables(store); self.confirmed_root = self.get_latest_confirmed(store); @@ -157,7 +157,7 @@ impl FastConfirmationStore

{ /// /// Roughly corresponds to [`update_fast_confirmation_variables`] from the Fast Confirmation specification. /// - /// [`update_fast_confirmation_variables`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#update_fast_confirmation_variables + /// [`update_fast_confirmation_variables`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#update_fast_confirmation_variables fn update_variables>(&mut self, store: &Store) { let current_slot = store.slot(); let current_head = store.head().block_root; @@ -206,7 +206,7 @@ impl FastConfirmationStore

{ /// /// Roughly corresponds to [`get_latest_confirmed`] from the Fast Confirmation specification. /// - /// [`get_latest_confirmed`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#get_latest_confirmed + /// [`get_latest_confirmed`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#get_latest_confirmed #[expect( clippy::too_many_lines, reason = "Direct translation of the five-step `get_latest_confirmed` algorithm from \ @@ -237,11 +237,10 @@ impl FastConfirmationStore

{ } else if is_epoch_start { // spec: is_confirmed_chain_safe — build chain with the previous balance source. let safe = (|| -> Option { - let obs_slot = store - .chain_link(self.current_epoch_observed_justified_checkpoint.root)? - .slot(); - let obs_anc = store.ancestor(self.confirmed_root, obs_slot)?; - if obs_anc != self.current_epoch_observed_justified_checkpoint.root { + let obs_cp = self.current_epoch_observed_justified_checkpoint; + let target_slot = misc::compute_start_slot_at_epoch::

(obs_cp.epoch); + let cp_block = store.ancestor(self.confirmed_root, target_slot)?; + if cp_block != obs_cp.root { return Some(false); } diff --git a/fork_choice_store/src/store.rs b/fork_choice_store/src/store.rs index 4424bc873..a0facfac5 100644 --- a/fork_choice_store/src/store.rs +++ b/fork_choice_store/src/store.rs @@ -985,7 +985,7 @@ impl> Store { /// This should never return `None` in normal operation, but the reasons for that are slightly /// different at each call site, so we call `Option::expect` every time we use this instead of /// changing the type. - pub(crate) fn ancestor(&self, descendant_root: H256, ancestor_slot: Slot) -> Option { + pub fn ancestor(&self, descendant_root: H256, ancestor_slot: Slot) -> Option { if let Some(location) = self.unfinalized_locations.get(&descendant_root).copied() { let descendant_segment = &self.unfinalized[&location.segment_id]; @@ -4665,7 +4665,7 @@ impl> Store { /// handles both the standard `±1`-epoch range and the `current_epoch − 2` reconfirmation /// case via its randao-mixes fallback. /// - /// [`get_equivocation_score`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#get_equivocation_score + /// [`get_equivocation_score`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#get_equivocation_score fn fcr_get_equivocation_score( &self, from_slot: Slot, @@ -4695,7 +4695,7 @@ impl> Store { /// /// Roughly corresponds to [`get_attestation_score`] from the Fork Choice specification. /// - /// [`get_attestation_score`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fork-choice.md#get_attestation_score + /// [`get_attestation_score`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fork-choice.md#get_attestation_score fn fcr_get_attestation_score(&self, root: H256, slot: Slot, active_balances: &[Gwei]) -> Gwei { self.latest_messages .iter() @@ -4725,7 +4725,7 @@ impl> Store { /// /// Roughly corresponds to [`compute_empty_slot_support_discount`] from the Fast Confirmation specification. /// - /// [`compute_empty_slot_support_discount`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/phase0/fast-confirmation.md#compute_empty_slot_support_discount + /// [`compute_empty_slot_support_discount`]: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.9/specs/phase0/fast-confirmation.md#compute_empty_slot_support_discount fn fcr_precompute_support_discount( &self, parent_slot: Slot, diff --git a/prometheus_metrics/src/metrics.rs b/prometheus_metrics/src/metrics.rs index 8e4da70d5..841fd47ff 100644 --- a/prometheus_metrics/src/metrics.rs +++ b/prometheus_metrics/src/metrics.rs @@ -178,6 +178,14 @@ pub struct Metrics { pub beacon_reorgs_total: IntCounter, + // Fast Confirmation Rule + beacon_fast_confirmation_enabled: IntGauge, + beacon_fast_confirmation_confirmed_slot: IntGauge, + beacon_fast_confirmation_confirmed_lag_slots: IntGauge, + pub beacon_fast_confirmation_advances_total: IntCounter, + pub beacon_fast_confirmation_resets_total: IntCounter, + pub beacon_fast_confirmation_duration_seconds: Histogram, + beacon_processed_deposits_total: IntGauge, // Extra EF interop metrics: not available in the docs yet @@ -774,6 +782,34 @@ impl Metrics { "Total number of chain reorganizations", )?, + // Fast Confirmation Rule + beacon_fast_confirmation_enabled: IntGauge::new( + "beacon_fast_confirmation_enabled", + "1 when the Fast Confirmation Rule is enabled at runtime, 0 otherwise", + )?, + beacon_fast_confirmation_confirmed_slot: IntGauge::new( + "beacon_fast_confirmation_confirmed_slot", + "Slot of the most recent block confirmed by the Fast Confirmation Rule", + )?, + beacon_fast_confirmation_confirmed_lag_slots: IntGauge::new( + "beacon_fast_confirmation_confirmed_lag_slots", + "Lag in slots between the current store slot and the most recent FCR-confirmed block", + )?, + beacon_fast_confirmation_advances_total: IntCounter::new( + "beacon_fast_confirmation_advances_total", + "Total times FCR advanced the confirmed root forward to a descendant", + )?, + beacon_fast_confirmation_resets_total: IntCounter::new( + "beacon_fast_confirmation_resets_total", + "Total times FCR changed the confirmed root to a non-descendant (reset to finalized \ + or reset+advance after a chain reorg — signals synchrony assumption broken)", + )?, + beacon_fast_confirmation_duration_seconds: Histogram::with_opts(histogram_opts!( + "beacon_fast_confirmation_duration_seconds", + "Time to run a single Fast Confirmation Rule pass (update_variables + get_latest_confirmed)", + vec![0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 2.0] + ))?, + beacon_processed_deposits_total: IntGauge::new( "beacon_processed_deposits_total", "Total number of deposits processed", @@ -1030,6 +1066,14 @@ impl Metrics { default_registry.register(Box::new(self.beacon_previous_justified_epoch.clone()))?; default_registry.register(Box::new(self.beacon_current_active_validators.clone()))?; default_registry.register(Box::new(self.beacon_reorgs_total.clone()))?; + default_registry.register(Box::new(self.beacon_fast_confirmation_enabled.clone()))?; + default_registry.register(Box::new(self.beacon_fast_confirmation_confirmed_slot.clone()))?; + default_registry + .register(Box::new(self.beacon_fast_confirmation_confirmed_lag_slots.clone()))?; + default_registry.register(Box::new(self.beacon_fast_confirmation_advances_total.clone()))?; + default_registry.register(Box::new(self.beacon_fast_confirmation_resets_total.clone()))?; + default_registry + .register(Box::new(self.beacon_fast_confirmation_duration_seconds.clone()))?; default_registry.register(Box::new(self.beacon_processed_deposits_total.clone()))?; default_registry.register(Box::new( self.beacon_participation_prev_epoch_active_gwei_total @@ -1309,6 +1353,20 @@ impl Metrics { .set(validator_count as i64); } + pub fn set_beacon_fast_confirmation_enabled(&self, enabled: bool) { + self.beacon_fast_confirmation_enabled + .set(i64::from(enabled)); + } + + pub fn set_beacon_fast_confirmation_confirmed_slot(&self, slot: Slot) { + self.beacon_fast_confirmation_confirmed_slot.set(slot as i64); + } + + pub fn set_beacon_fast_confirmation_confirmed_lag_slots(&self, lag: u64) { + self.beacon_fast_confirmation_confirmed_lag_slots + .set(lag as i64); + } + pub fn set_beacon_processed_deposits_total(&self, total_deposits: u64) { self.beacon_processed_deposits_total .set(total_deposits as i64); diff --git a/scripts/download_spec_tests.sh b/scripts/download_spec_tests.sh index 71cffd6a9..4377bb5a5 100755 --- a/scripts/download_spec_tests.sh +++ b/scripts/download_spec_tests.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -SPEC_VERSION="${SPEC_VERSION:-v1.7.0-alpha.5}" +SPEC_VERSION="${SPEC_VERSION:-v1.7.0-alpha.9}" TESTS_DIR="consensus-spec-tests" VERSION_FILE="${TESTS_DIR}/.version" BASE_URL="https://github.com/ethereum/consensus-specs/releases/download/${SPEC_VERSION}" From 3bfe14bab7708946c8c23c7fec8fbf23fc3ec632 Mon Sep 17 00:00:00 2001 From: bomanaps Date: Mon, 22 Jun 2026 15:43:22 +0100 Subject: [PATCH 7/7] emit fast_confirmation every run with current_slot --- fork_choice_control/src/events.rs | 32 +++++++++++++++++++++--------- fork_choice_control/src/mutator.rs | 15 ++++++-------- prometheus_metrics/src/metrics.rs | 21 +++++++++++++------- 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/fork_choice_control/src/events.rs b/fork_choice_control/src/events.rs index d1a9fbb8b..838c4b0a5 100644 --- a/fork_choice_control/src/events.rs +++ b/fork_choice_control/src/events.rs @@ -281,8 +281,8 @@ impl EventChannels

{ } } - pub fn send_fast_confirmation_event(&self, block: H256, slot: Slot) { - if let Err(error) = self.send_fast_confirmation_event_internal(block, slot) { + pub fn send_fast_confirmation_event(&self, block: H256, slot: Slot, current_slot: Slot) { + if let Err(error) = self.send_fast_confirmation_event_internal(block, slot, current_slot) { warn_with_peers!("unable to send fast confirmation event: {error}"); } } @@ -516,9 +516,18 @@ impl EventChannels

{ Ok(()) } - fn send_fast_confirmation_event_internal(&self, block: H256, slot: Slot) -> Result<()> { + fn send_fast_confirmation_event_internal( + &self, + block: H256, + slot: Slot, + current_slot: Slot, + ) -> Result<()> { if self.fast_confirmations.receiver_count() > 0 { - let event = Event::FastConfirmation(FastConfirmationEvent { block, slot }); + let event = Event::FastConfirmation(FastConfirmationEvent { + block, + slot, + current_slot, + }); self.fast_confirmations.send(event)?; } @@ -676,20 +685,25 @@ pub struct BlockGossipEvent { pub block: H256, } -/// SSE event payload for the `fast_confirmation` topic ([beacon-APIs PR #598]). +/// SSE event payload for the `fast_confirmation` topic ([beacon-APIs PR #598], extended by +/// [beacon-APIs PR #616]). /// -/// Emitted from `mutator.rs` after `FastConfirmationStore::on_fast_confirmation` runs and the -/// confirmed root changes (matches Nimbus PR #8479's `if curr == prev: return` emit gate). +/// Emitted from `mutator.rs` once per slot, after `FastConfirmationStore::on_fast_confirmation` +/// runs — regardless of whether the most recent confirmed block changed since the previous run. +/// `current_slot` lets consumers distinguish a fresh re-confirmation of the same root from a +/// stale stream. /// -/// JSON wire form per the spec: `{"block": "0x...", "slot": "N"}`. The Rust field is named -/// `block` directly (not `block_root`) so no `#[serde(rename)]` is needed. +/// JSON wire form: `{"block": "0x...", "slot": "N", "current_slot": "M"}`. /// /// [beacon-APIs PR #598]: https://github.com/ethereum/beacon-APIs/pull/598 +/// [beacon-APIs PR #616]: https://github.com/ethereum/beacon-APIs/pull/616 #[derive(Clone, Copy, Debug, Serialize)] pub struct FastConfirmationEvent { pub block: H256, #[serde(with = "serde_utils::string_or_native")] pub slot: Slot, + #[serde(with = "serde_utils::string_or_native")] + pub current_slot: Slot, } #[derive(Clone, Debug, Serialize)] diff --git a/fork_choice_control/src/mutator.rs b/fork_choice_control/src/mutator.rs index f7f4d6a9e..e8f414f09 100644 --- a/fork_choice_control/src/mutator.rs +++ b/fork_choice_control/src/mutator.rs @@ -511,10 +511,6 @@ where // FCR: run on_fast_confirmation once per slot, after past-slot attestations have been // applied by `apply_tick`. Spec: `update_fast_confirmation_variables` MUST be called // only once per slot; `is_slot_updated()` suppresses intra-slot tick updates. - // - // Emits the `fast_confirmation` SSE event (beacon-APIs PR #598) when the confirmed - // root changes — matches Nimbus PR #8479's `if curr == prev: return` emit gate. - // Updates Prometheus FCR metrics each tick (gauges always-fresh; counters only on change). if changes.is_slot_updated() && let Some(fcr) = self.fcr_store.as_mut() { @@ -539,14 +535,15 @@ where .chain_link(current_confirmed) .map(ChainLink::slot) .unwrap_or(0); + let store_slot = self.store.slot(); - if current_confirmed != previous_confirmed { - self.event_channels - .send_fast_confirmation_event(current_confirmed, current_confirmed_slot); - } + self.event_channels.send_fast_confirmation_event( + current_confirmed, + current_confirmed_slot, + store_slot, + ); if let Some(metrics) = self.metrics.as_ref() { - let store_slot = self.store.slot(); metrics.set_beacon_fast_confirmation_confirmed_slot(current_confirmed_slot); metrics.set_beacon_fast_confirmation_confirmed_lag_slots( store_slot.saturating_sub(current_confirmed_slot), diff --git a/prometheus_metrics/src/metrics.rs b/prometheus_metrics/src/metrics.rs index 841fd47ff..af4938e92 100644 --- a/prometheus_metrics/src/metrics.rs +++ b/prometheus_metrics/src/metrics.rs @@ -1067,13 +1067,19 @@ impl Metrics { default_registry.register(Box::new(self.beacon_current_active_validators.clone()))?; default_registry.register(Box::new(self.beacon_reorgs_total.clone()))?; default_registry.register(Box::new(self.beacon_fast_confirmation_enabled.clone()))?; - default_registry.register(Box::new(self.beacon_fast_confirmation_confirmed_slot.clone()))?; - default_registry - .register(Box::new(self.beacon_fast_confirmation_confirmed_lag_slots.clone()))?; - default_registry.register(Box::new(self.beacon_fast_confirmation_advances_total.clone()))?; + default_registry.register(Box::new( + self.beacon_fast_confirmation_confirmed_slot.clone(), + ))?; + default_registry.register(Box::new( + self.beacon_fast_confirmation_confirmed_lag_slots.clone(), + ))?; + default_registry.register(Box::new( + self.beacon_fast_confirmation_advances_total.clone(), + ))?; default_registry.register(Box::new(self.beacon_fast_confirmation_resets_total.clone()))?; - default_registry - .register(Box::new(self.beacon_fast_confirmation_duration_seconds.clone()))?; + default_registry.register(Box::new( + self.beacon_fast_confirmation_duration_seconds.clone(), + ))?; default_registry.register(Box::new(self.beacon_processed_deposits_total.clone()))?; default_registry.register(Box::new( self.beacon_participation_prev_epoch_active_gwei_total @@ -1359,7 +1365,8 @@ impl Metrics { } pub fn set_beacon_fast_confirmation_confirmed_slot(&self, slot: Slot) { - self.beacon_fast_confirmation_confirmed_slot.set(slot as i64); + self.beacon_fast_confirmation_confirmed_slot + .set(slot as i64); } pub fn set_beacon_fast_confirmation_confirmed_lag_slots(&self, lag: u64) {