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/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/factory/src/lib.rs b/factory/src/lib.rs index 8cd286eca..2558f22da 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: 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/fork_choice_control/src/controller.rs b/fork_choice_control/src/controller.rs index 6884dd9d3..0016f7295 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,17 @@ 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)); + 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(); let store_snapshot = Arc::new(ArcSwap::from_pointee(store)); let thread_pool = ThreadPool::new()?; @@ -165,6 +178,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 +213,7 @@ where let controller = Arc::new(Self { store_snapshot, + fcr_snapshot, block_processor, execution_engine, pubkey_cache, @@ -885,6 +900,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/events.rs b/fork_choice_control/src/events.rs index 5ebc91d71..838c4b0a5 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, 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}"); + } + } + pub fn send_finalized_checkpoint_event( &self, block_root: H256, @@ -504,6 +516,24 @@ impl EventChannels

{ Ok(()) } + 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, + current_slot, + }); + self.fast_confirmations.send(event)?; + } + + Ok(()) + } + fn send_finalized_checkpoint_event_internal( &self, block_root: H256, @@ -655,6 +685,27 @@ pub struct BlockGossipEvent { pub block: H256, } +/// SSE event payload for the `fast_confirmation` topic ([beacon-APIs PR #598], extended by +/// [beacon-APIs PR #616]). +/// +/// 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: `{"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)] pub struct DataColumnSidecarEvent { pub block_root: H256, diff --git a/fork_choice_control/src/extra_tests.rs b/fork_choice_control/src/extra_tests.rs index c997e87f3..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; @@ -2522,3 +2528,226 @@ fn reorganizing_due_to_invalidation_sends_notifications_if_common_ancestor_is_un unfinalized_block_count_total: 1, }); } + +// ───────────────────────────────────────────────────────────────────────────── +// Fast Confirmation Rule (FCR) tests +// ───────────────────────────────────────────────────────────────────────────── + +/// 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_returns_no_confirmed_root() { + 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); + + assert_eq!(context.confirmed_root(), None); +} + +/// 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 mut context = Context::minimal_with_fcr(); + + 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() + .expect("FCR is enabled — confirmed_root() must return Some"); + + 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 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)); + // 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() + .expect("FCR is enabled — confirmed_root() must return Some"); + + 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 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 (`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(); + + 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 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)); + + // 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() + .expect("FCR is enabled — confirmed_root() must return Some"); + + 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-2 block, epoch-2 checkpoint) at epoch 3, +/// then verifies that advancing to epoch 4 triggers the age-revert condition: +/// 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, 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); + 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() + .expect("FCR is enabled — confirmed_root() must return Some"); + + 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 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() + .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 9c858ee43..cccc9de72 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() { @@ -162,6 +181,21 @@ impl Context

{ self.controller().last_finalized_state().value } + #[must_use] + pub fn confirmed_root(&self) -> Option { + self.controller().confirmed_root() + } + + #[must_use] + pub fn finalized_root(&self) -> H256 { + self.controller().finalized_root() + } + + #[must_use] + pub fn justified_checkpoint(&self) -> 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 +308,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: 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; @@ -547,6 +606,42 @@ 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; @@ -724,6 +819,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/mutator.rs b/fork_choice_control/src/mutator.rs index 996e5aeb3..e8f414f09 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,65 @@ 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() + { + 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); + let store_slot = self.store.slot(); + + self.event_channels.send_fast_confirmation_event( + current_confirmed, + current_confirmed_slot, + store_slot, + ); + + if let Some(metrics) = self.metrics.as_ref() { + 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(); + } + } + } + } + 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(), @@ -2752,7 +2818,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 +3509,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 +3770,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 1124e5130..19cad61a2 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,6 +84,62 @@ where self.store_snapshot().finalized_root() } + /// 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) -> 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] pub fn genesis_time(&self) -> UnixSeconds { let store = self.store_snapshot(); @@ -231,10 +288,25 @@ where }) .collect(); + 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 { justified_checkpoint: store.justified_checkpoint(), finalized_checkpoint: store.finalized_checkpoint(), fork_choice_nodes, + extra_data, } } @@ -824,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(), } @@ -1039,6 +1112,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 `store_config.fast_confirmation_rule` 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)] @@ -1095,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

, } @@ -1181,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 0cb26c156..2f9ccba6a 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; @@ -68,6 +68,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 +167,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.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( + 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,6 +216,7 @@ async fn run_case(config: &Arc, case: Case<'_>) { anchor_block, anchor_state, false, + fast_confirmation_rule, ); let mut last_payload_status: Option = None; @@ -279,6 +311,35 @@ 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(types::traits::BlockBodyWithExecutionPayload::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 @@ -325,6 +386,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 { @@ -351,6 +418,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_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/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..bba82253a --- /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.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; + +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 new file mode 100644 index 000000000..42b6a8ce6 --- /dev/null +++ b/fork_choice_store/src/fast_confirmation/mod.rs @@ -0,0 +1,26 @@ +//! 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 committees; +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::committees::fcr_slot_committee_participants; +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..0de134a3b --- /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.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, + 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.9/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.9/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.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 +} + +/// 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.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, + 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.9/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.9/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.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 + .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.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 + && 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.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; + } + 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.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) +} + +/// 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.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 \ + `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..376946338 --- /dev/null +++ b/fork_choice_store/src/fast_confirmation/store.rs @@ -0,0 +1,414 @@ +//! `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.9/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) +} + +/// 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.9/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. +/// +/// [`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. + 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.9/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.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); + } + + /// 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.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; + + 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. 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) + }); + + if let Some((_state, balances, total)) = ffg_inputs { + self.ffg_total_active_balance = 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; + 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.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 \ + 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_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); + } + + 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, + &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; + } + + // 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); + + // 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 + .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 d9292905e..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,6 +96,7 @@ pub use crate::{ mod blob_cache; mod data_column_cache; mod error; +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..a0facfac5 100644 --- a/fork_choice_store/src/store.rs +++ b/fork_choice_store/src/store.rs @@ -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, @@ -984,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 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]; @@ -1036,6 +1037,10 @@ impl> Store { self.checkpoint_states.get(&checkpoint) } + pub const 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) @@ -1045,14 +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 { - self.justified_chain_link() - .and_then(ChainLink::execution_block_hash) - .unwrap_or_default() - } - #[must_use] pub fn finalized_execution_payload_hash(&self) -> ExecutionBlockHash { // > As per EIP-3675, before a post-transition block is finalized, @@ -3949,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 @@ -4659,4 +4656,416 @@ impl> Store { .sum(), ); } + + /// 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.9/specs/phase0/fast-confirmation.md#get_equivocation_score + fn fcr_get_equivocation_score( + &self, + 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 { + for vi in fast_confirmation::fcr_slot_committee_participants::

(&head_state, s) { + if self.equivocating_indices.contains(&vi) { + eq_set.insert(vi); + } + } + } + eq_set + .iter() + .filter_map(|&i| { + let idx = usize::try_from(i).ok()?; + active_balances.get(idx).copied() + }) + .filter(|&b| b > 0) + .sum() + } + + /// Sums active balances of validators whose latest message descends through `root` at `slot`. + /// + /// 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.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() + .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() + } + + /// Pre-computes the empty-slot support discount for a block; returns 0 when `parent_slot + 1 == slot`. + /// + /// 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.9/specs/phase0/fast-confirmation.md#compute_empty_slot_support_discount + fn fcr_precompute_support_discount( + &self, + parent_slot: Slot, + slot: Slot, + parent_root: H256, + 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` 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 { + participants.extend(fast_confirmation::fcr_slot_committee_participants::

( + &head_state, + s, + )); + } + let parent_support_in_empty: Gwei = participants + .iter() + .filter_map(|&i| { + 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(idx)?.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(empty_start, empty_end, active_balances) + }; + 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) + } + + /// 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`. + /// + /// `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, + 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 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!( + 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); + + // 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, + cl.current_justified_checkpoint.epoch, + ); + + let seen_by_prev_head = self + .ancestor(previous_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 && !self.equivocating_indices.is_empty() + { + self.fcr_get_equivocation_score(adv_start_slot, end_slot, active_balances) + } 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 = self.fcr_precompute_support_discount( + parent_slot, + slot, + parent_root, + active_balances, + total_active_balance, + ); + + Some(fast_confirmation::ChainInfo { + block_root: root, + epoch, + voting_source_epoch, + seen_by_prev_head, + support, + adversarial, + committee_weight, + 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(), + byzantine_threshold: self.chain_config.confirmation_byzantine_threshold, + }) + }) + .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()`. + /// + /// 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, + honest_ffg_support: Gwei, + 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 + }; + + // 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 + }; + + fast_confirmation::FcrFfgData { + honest_ffg_support, + total_active_balance, + 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, + } + } } 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/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 6d763fcb8..8ae97026b 100644 --- a/http_api/src/block_id.rs +++ b/http_api/src/block_id.rs @@ -26,6 +26,15 @@ pub fn block( .map(|checkpoint| checkpoint.block) .map(WithStatus::valid_and_finalized), BlockId::Finalized => Some(controller.last_finalized_block()), + 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)), @@ -48,6 +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 => 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/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), 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:?}")] diff --git a/prometheus_metrics/src/metrics.rs b/prometheus_metrics/src/metrics.rs index 8e4da70d5..af4938e92 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,20 @@ 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 +1359,21 @@ 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/runtime/src/grandine_args.rs b/runtime/src/grandine_args.rs index 7cf3bc670..2848c9bd2 100644 --- a/runtime/src/grandine_args.rs +++ b/runtime/src/grandine_args.rs @@ -482,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( @@ -1082,6 +1086,7 @@ impl GrandineArgs { kzg_backend, blacklisted_blocks, sync_without_reconstruction, + fast_confirmation_rule, .. } = beacon_node_options; @@ -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)?); diff --git a/scripts/download_spec_tests.sh b/scripts/download_spec_tests.sh index ededa3e7b..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.4}" +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}" 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(),