From 436b9c109d04b405e529ee535331b020418f1c0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:01:59 -0300 Subject: [PATCH 1/4] feat(config): read the slot duration from the network config file The slot duration was a compile-time constant, so trying a different cadence on a devnet meant rebuilding and reshipping every client. It is a network-wide parameter that belongs with the other things every node on a chain has to agree on, next to `GENESIS_TIME`. `config.yaml` gains an optional `MILLISECONDS_PER_SLOT`, defaulting to the 4000 the whole network already ran on. It must be a positive multiple of `INTERVALS_PER_SLOT`, rejected at parse time otherwise: the five intervals are cut from it and every duty is scheduled off those boundaries, so a value that does not divide evenly would drift the grid against the wall clock. `INTERVALS_PER_SLOT` stays a constant. Each interval carries a distinct duty, so the count is part of the protocol rather than a knob. The value cannot go into the config the state carries, which is merkleized into the state root and whose layout is fixed by the spec. That type is renamed `StateConfig`, and `ChainConfig` now names what the node itself persists under `Metadata["config"]`: genesis time plus slot duration. Every consumer reads it through `Store::config`, which already carried the genesis time. Values derived from the cadence now follow it: interval length, the aggregation deadline (one interval), the early-aggregation window, and gossipsub's duplicate cache, which the spec defines in slots. The early-aggregation window is now clamped to one interval rather than guarded by a const assert. It is subtracted from an interval offset in two places, and a short enough cadence would make the nominal 600 ms wider than an interval and underflow both. Compatibility: - A data directory written before this change holds a bare SSZ `StateConfig` under `Metadata["config"]`. It decodes as the 4-second default, which is the only cadence it could have run, so existing nodes resume rather than fail. - Resuming a data directory whose persisted cadence disagrees with the config file is refused with `GenesisMismatch::SlotDuration`. Its blocks are indexed against a different time grid, which makes it as foreign as another genesis, and the state cannot reveal this because the duration is deliberately absent from it. - Other clients hold the value at compile time and ignore unknown config keys, so setting it only takes effect on a network where every node reads it. --- CLAUDE.md | 7 +- bin/ethlambda/src/checkpoint_sync.rs | 4 +- bin/ethlambda/src/main.rs | 28 ++- crates/blockchain/src/aggregation.rs | 63 +++--- crates/blockchain/src/block_builder.rs | 20 +- crates/blockchain/src/events.rs | 7 +- crates/blockchain/src/lib.rs | 213 +++++++++++++----- crates/blockchain/src/metrics.rs | 53 +++-- crates/blockchain/src/reaggregate.rs | 7 +- crates/blockchain/src/spec_test_runner.rs | 13 +- crates/blockchain/src/store.rs | 55 ++++- crates/blockchain/state_transition/src/lib.rs | 10 +- .../blockchain/tests/forkchoice_spectests.rs | 10 +- .../blockchain/tests/signature_spectests.rs | 15 +- crates/common/test-fixtures/src/common.rs | 6 +- crates/common/types/src/chain_config.rs | 107 +++++++++ crates/common/types/src/constants.rs | 18 +- crates/common/types/src/genesis.rs | 130 +++++++++++ crates/common/types/src/lib.rs | 1 + crates/common/types/src/state.rs | 16 +- crates/common/types/tests/ssz_spectests.rs | 2 +- crates/net/p2p/src/lib.rs | 12 +- crates/net/p2p/src/req_resp/handlers.rs | 7 +- crates/net/rpc/src/events.rs | 7 +- crates/net/rpc/src/fork_choice.rs | 5 +- crates/net/rpc/src/genesis.rs | 7 +- crates/net/rpc/src/lib.rs | 24 +- crates/net/rpc/src/node.rs | 35 ++- crates/net/rpc/src/spec.rs | 56 +++-- crates/net/rpc/src/test_driver.rs | 11 +- crates/storage/src/store.rs | 198 +++++++++++++--- docs/checkpoint_sync.md | 2 +- docs/data_storage.md | 31 ++- docs/lmd_ghost.md | 10 +- docs/metrics.md | 2 + docs/rpc.md | 5 +- docs/slots_and_intervals.md | 15 +- 37 files changed, 940 insertions(+), 272 deletions(-) create mode 100644 crates/common/types/src/chain_config.rs diff --git a/CLAUDE.md b/CLAUDE.md index a4b14c91..5d2c6d08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ crates/ - Communication via `mpsc::unbounded_channel` - Shared storage via `Arc` (clone Store, share backend) -### Tick-Based Validator Duties (4-second slots, 5 intervals per slot) +### Tick-Based Validator Duties (5 intervals per slot; 4-second slots by default) ``` Interval 0: Block published (at the slot boundary). The build+publish code path is merged into the previous slot's interval 4 (see below) and aligned to publish here; no attestation acceptance happens at interval 0. Interval 1: Attestation production (all validators, including proposer) @@ -306,11 +306,16 @@ one port is supported and not a misconfiguration. See [`docs/rpc.md`](docs/rpc.m **Genesis:** `config.yaml` (YAML format, cross-client compatible) ```yaml GENESIS_TIME: 1770407233 +MILLISECONDS_PER_SLOT: 4000 # optional, defaults to DEFAULT_MILLISECONDS_PER_SLOT GENESIS_VALIDATORS: - attestation_pubkey: "cd323f232b34ab26d6db7402c886e74ca81cfd3a..." # 52-byte XMSS pubkeys (hex) proposal_pubkey: "b7b0f72e24801b02bda64073cb4de6699a416b37..." ``` - Validator indices are assigned sequentially (0, 1, 2, ...) based on array order +- `MILLISECONDS_PER_SLOT` must be a positive multiple of `INTERVALS_PER_SLOT`; it is + persisted in the DB's `Metadata["config"]` and a resume with a different value is + refused. Other clients ignore the key and stay at their compile-time 4s, so it only + takes effect on an all-ethlambda network - All genesis state fields (checkpoints, justified_slots, etc.) initialize to zero/empty defaults - Matches Ream/Zeam format — no extra state fields in the config file diff --git a/bin/ethlambda/src/checkpoint_sync.rs b/bin/ethlambda/src/checkpoint_sync.rs index e73f0e8f..e18ef8b6 100644 --- a/bin/ethlambda/src/checkpoint_sync.rs +++ b/bin/ethlambda/src/checkpoint_sync.rs @@ -341,7 +341,7 @@ mod tests { use ethlambda_types::block::BlockHeader; use ethlambda_types::checkpoint::Checkpoint; use ethlambda_types::primitives::H256; - use ethlambda_types::state::{ChainConfig, JustificationValidators, JustifiedSlots}; + use ethlambda_types::state::{JustificationValidators, JustifiedSlots, StateConfig}; use libssz_types::SszList; // Helper to create valid test state @@ -364,7 +364,7 @@ mod tests { slot: slot.saturating_sub(20), root: H256::ZERO, }, - config: ChainConfig { genesis_time }, + config: StateConfig { genesis_time }, historical_block_hashes: Default::default(), justified_slots: JustifiedSlots::new(), justifications_roots: Default::default(), diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index a32ac594..adbb21f0 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -34,7 +34,6 @@ use tokio_util::sync::CancellationToken; use command::Command; -use ethlambda_blockchain::MILLISECONDS_PER_SLOT; use ethlambda_blockchain::block_builder::ProposerConfig; use ethlambda_blockchain::key_manager::ValidatorKeyPair; use ethlambda_crypto::signature::ValidatorSecretKey; @@ -160,6 +159,7 @@ async fn main() -> eyre::Result<()> { info!( genesis_time = genesis_config.genesis_time, + milliseconds_per_slot = genesis_config.milliseconds_per_slot, validator_count = genesis_config.genesis_validators.len(), "Loaded genesis configuration" ); @@ -270,6 +270,7 @@ async fn main() -> eyre::Result<()> { validator_ids, attestation_committee_count, subscription_subnets: subscribed_subnets.clone(), + milliseconds_per_slot: genesis_config.milliseconds_per_slot, }) .wrap_err("failed to build swarm")?; @@ -713,7 +714,7 @@ async fn fetch_initial_state( .expect("already past the unix epoch") .as_millis() as u64; let current_slot = - now_ms.saturating_sub(genesis.genesis_time * 1000) / MILLISECONDS_PER_SLOT; + now_ms.saturating_sub(genesis.genesis_time * 1000) / genesis.milliseconds_per_slot; let head_slot = store.head_slot(); let gap = current_slot.saturating_sub(head_slot); if gap <= MAX_RESUMABLE_DB_STATE_AGE { @@ -737,7 +738,11 @@ async fn fetch_initial_state( if checkpoint_urls.is_empty() { info!("No checkpoint sync URL provided, initializing from genesis state"); let genesis_state = State::from_genesis(genesis.genesis_time, validators); - return Ok(Store::from_anchor_state(backend, genesis_state)); + return Ok(Store::from_anchor_state( + backend, + genesis_state, + genesis.milliseconds_per_slot, + )); } // Checkpoint sync path: try URLs in order, fail over to the next on error. @@ -763,9 +768,14 @@ async fn fetch_initial_state( // overlaps with what `get_forkchoice_store` already wrote, but it's // idempotent and the only path that also stores `BlockProof`. let anchor_root = signed_block.message.header().hash_tree_root(); - let mut store = Store::get_forkchoice_store(backend, state, signed_block.message.clone()) - .inspect_err(|err| error!(%err, "Failed to initialize store from anchor state and block")) - .map_err(|_| checkpoint_sync::CheckpointSyncError::AnchorPairingMismatch)?; + let mut store = Store::get_forkchoice_store( + backend, + state, + signed_block.message.clone(), + genesis.milliseconds_per_slot, + ) + .inspect_err(|err| error!(%err, "Failed to initialize store from anchor state and block")) + .map_err(|_| checkpoint_sync::CheckpointSyncError::AnchorPairingMismatch)?; store .insert_signed_block(anchor_root, signed_block) .inspect_err(|err| error!(%err, "Failed to insert anchor signed block into store")) @@ -777,6 +787,7 @@ async fn fetch_initial_state( mod tests { use super::*; use ethlambda_storage::backend::InMemoryBackend; + use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT; use ethlambda_types::genesis::GenesisValidatorEntry; /// Validator-config snippet matching `lean-quickstart`'s ansible-devnet @@ -893,7 +904,7 @@ validators: /// elapsed time, and a whole slot of it would have to pass between this /// call and the read inside the function to shift the gap. fn genesis_time_for_gap(gap: u64) -> u64 { - let seconds_per_slot = MILLISECONDS_PER_SLOT / 1_000; + let seconds_per_slot = DEFAULT_MILLISECONDS_PER_SLOT / 1_000; now_secs() - (SEEDED_HEAD_SLOT + gap) * seconds_per_slot } @@ -902,6 +913,7 @@ validators: fn test_genesis(genesis_time: u64) -> GenesisConfig { GenesisConfig { genesis_time, + milliseconds_per_slot: DEFAULT_MILLISECONDS_PER_SLOT, genesis_validators: vec![GenesisValidatorEntry { attestation_pubkey: [1u8; 52], proposal_pubkey: [2u8; 52], @@ -915,7 +927,7 @@ validators: let mut anchor = State::from_genesis(genesis.genesis_time, genesis.validators()); anchor.slot = SEEDED_HEAD_SLOT; anchor.latest_block_header.slot = SEEDED_HEAD_SLOT; - Store::from_anchor_state(backend, anchor); + Store::from_anchor_state(backend, anchor, DEFAULT_MILLISECONDS_PER_SLOT); } #[tokio::test] diff --git a/crates/blockchain/src/aggregation.rs b/crates/blockchain/src/aggregation.rs index d8249c76..69e0bcad 100644 --- a/crates/blockchain/src/aggregation.rs +++ b/crates/blockchain/src/aggregation.rs @@ -2,7 +2,7 @@ //! pure functions it runs. //! //! The blockchain actor fires one aggregation session per slot — at interval 2, -//! or up to [`EARLY_AGGREGATION_WINDOW`] early when the 2/3 signature +//! or up to [`early_aggregation_window`] early when the 2/3 signature //! threshold is met — via //! [`run_aggregation_worker`]. The actor stays on its message loop; the worker //! runs the expensive XMSS proofs on a `spawn_blocking` thread and streams @@ -36,35 +36,41 @@ use tokio_util::sync::CancellationToken; use tracing::{info, trace, warn}; use crate::block_builder::{self, EntryScore}; -use crate::{MILLISECONDS_PER_INTERVAL, metrics}; +use crate::metrics; /// Soft deadline for committee-signature aggregation measured from session -/// start. After this much wall time elapses, the actor signals the worker to -/// stop via its cancellation token. A session started exactly at interval 2 -/// gets the full interval (interval 3 is one interval later); a session -/// started early (see `maybe_start_early_aggregation`) ends correspondingly -/// earlier. The deadline only stops new jobs from starting — a job mid-proof -/// finishes and publishes right after. -pub(crate) const AGGREGATION_DEADLINE: Duration = Duration::from_millis(800); +/// start: one full interval. After this much wall time elapses, the actor +/// signals the worker to stop via its cancellation token. A session started +/// exactly at interval 2 therefore runs until interval 3; a session started +/// early (see `maybe_start_early_aggregation`) ends correspondingly earlier. +/// The deadline only stops new jobs from starting — a job mid-proof finishes +/// and publishes right after. +pub(crate) fn aggregation_deadline(milliseconds_per_interval: u64) -> Duration { + Duration::from_millis(milliseconds_per_interval) +} + /// Upper bound we wait for a prior worker to exit if it is still running when /// the next session is about to start. Reached only in pathological cases /// (mismatched timers, stuck proofs); we warn before blocking. pub(crate) const PRIOR_WORKER_JOIN_TIMEOUT: Duration = Duration::from_secs(2); -/// Width of the early-aggregation window: a session may start at most this -/// long before the interval-2 boundary, provided the signature threshold is -/// met (see the check in `maybe_start_early_aggregation`). -pub(crate) const EARLY_AGGREGATION_WINDOW: Duration = Duration::from_millis(600); - -// The window must fit within one interval: `maybe_start_early_aggregation` -// subtracts it from the interval-2 offset, and the interval-1 tick schedules -// the check at `MILLISECONDS_PER_INTERVAL - EARLY_AGGREGATION_WINDOW`. Keep -// this invariant self-enforcing so a future bump to the window can't silently -// underflow either subtraction. -const _: () = assert!( - EARLY_AGGREGATION_WINDOW.as_millis() <= MILLISECONDS_PER_INTERVAL as u128, - "EARLY_AGGREGATION_WINDOW must not exceed one interval" -); +/// Nominal width of the early-aggregation window: a session may start at most +/// this long before the interval-2 boundary, provided the signature threshold +/// is met (see the check in `maybe_start_early_aggregation`). +const NOMINAL_EARLY_AGGREGATION_WINDOW_MS: u64 = 600; + +/// Width of the early-aggregation window, capped to one interval. +/// +/// The window must fit within one interval: `maybe_start_early_aggregation` +/// subtracts it from the interval-2 offset, and the interval-1 tick schedules +/// the check at `milliseconds_per_interval - window`. The slot duration is +/// configurable, so a short enough cadence can make the nominal window wider +/// than an interval; clamping here keeps both subtractions in range instead of +/// underflowing them, and keeps the invariant enforced at the one place the +/// window is produced. +pub(crate) fn early_aggregation_window(milliseconds_per_interval: u64) -> Duration { + Duration::from_millis(NOMINAL_EARLY_AGGREGATION_WINDOW_MS.min(milliseconds_per_interval)) +} /// A single pre-prepared aggregation group. /// @@ -165,7 +171,7 @@ impl Message for AggregationDeadline { } /// One-shot self-message scheduled at the interval-1 tick; fires when the -/// early-aggregation window opens (T2 - EARLY_AGGREGATION_WINDOW) to run +/// early-aggregation window opens (T2 - `early_aggregation_window`) to run /// the threshold check for signatures that all arrived before the window. /// Arrivals inside the window are checked per insert instead. pub(crate) struct EarlyAggregationCheck; @@ -174,7 +180,7 @@ impl Message for EarlyAggregationCheck { } /// Maximum number of aggregation jobs selected per interval-2 session. Caps -/// leanVM prover work against [`AGGREGATION_DEADLINE`]: the greedy loop in +/// leanVM prover work against [`aggregation_deadline`]: the greedy loop in /// [`snapshot_aggregation_inputs`] stops after this many rounds even if /// scoring candidates remain. pub(crate) const MAX_AGGREGATION_JOBS: usize = 2; @@ -772,10 +778,11 @@ pub(crate) fn run_aggregation_worker( mod tests { use super::*; use ethlambda_storage::backend::InMemoryBackend; + use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT; use ethlambda_types::{ block::{Block, BlockBody, BlockHeader, MultiMessageAggregate, SignedBlock}, checkpoint::Checkpoint, - state::{ChainConfig, JustificationValidators, JustifiedSlots, State}, + state::{JustificationValidators, JustifiedSlots, State, StateConfig}, }; use libssz_types::SszList; use std::sync::Arc; @@ -840,7 +847,7 @@ mod tests { body_root: H256::ZERO, }; State { - config: ChainConfig { genesis_time: 1000 }, + config: StateConfig { genesis_time: 1000 }, slot: head_slot, latest_block_header: head_header, latest_justified: Checkpoint::default(), @@ -855,7 +862,7 @@ mod tests { fn new_test_store(head_state: State) -> Store { let backend: Arc = Arc::new(InMemoryBackend::new()); - Store::from_anchor_state(backend, head_state) + Store::from_anchor_state(backend, head_state, DEFAULT_MILLISECONDS_PER_SLOT) } /// Insert a header-only block at `root` so it shows up in diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index 0b04eea5..f7f4f25e 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -972,7 +972,7 @@ mod tests { fn build_block_caps_attestation_data_entries() { use ethlambda_types::{ block::BlockHeader, - state::{ChainConfig, JustificationValidators, JustifiedSlots}, + state::{JustificationValidators, JustifiedSlots, StateConfig}, }; use libssz::SszEncode; use libssz_types::SszList; @@ -1008,7 +1008,7 @@ mod tests { }; let head_state = State { - config: ChainConfig { genesis_time: 1000 }, + config: StateConfig { genesis_time: 1000 }, slot: HEAD_SLOT, latest_block_header: head_header, latest_justified: Checkpoint::default(), @@ -1134,7 +1134,7 @@ mod tests { fn build_block_respects_configured_attestation_limit() { use ethlambda_types::{ block::BlockHeader, - state::{ChainConfig, JustificationValidators, JustifiedSlots}, + state::{JustificationValidators, JustifiedSlots, StateConfig}, }; use libssz_types::SszList; @@ -1165,7 +1165,7 @@ mod tests { }; let head_state = State { - config: ChainConfig { genesis_time: 1000 }, + config: StateConfig { genesis_time: 1000 }, slot: HEAD_SLOT, latest_block_header: head_header, latest_justified: Checkpoint::default(), @@ -1266,7 +1266,7 @@ mod tests { fn build_block_without_proposer_aggregation_keeps_single_best_proof_per_data() { use ethlambda_types::{ block::BlockHeader, - state::{ChainConfig, JustificationValidators, JustifiedSlots}, + state::{JustificationValidators, JustifiedSlots, StateConfig}, }; use libssz_types::SszList; @@ -1294,7 +1294,7 @@ mod tests { }; let head_state = State { - config: ChainConfig { genesis_time: 1000 }, + config: StateConfig { genesis_time: 1000 }, slot: HEAD_SLOT, latest_block_header: head_header, latest_justified: Checkpoint::default(), @@ -1568,7 +1568,7 @@ mod tests { use ethlambda_state_transition::justified_slots_ops; use ethlambda_types::{ block::BlockHeader, - state::{ChainConfig, JustificationValidators, JustifiedSlots}, + state::{JustificationValidators, JustifiedSlots, StateConfig}, }; use libssz_types::SszList; @@ -1601,7 +1601,7 @@ mod tests { }; let head_state = State { - config: ChainConfig { genesis_time: 1000 }, + config: StateConfig { genesis_time: 1000 }, slot: HEAD_SLOT, latest_block_header: head_header, latest_justified: Checkpoint { @@ -1697,7 +1697,7 @@ mod tests { fn build_block_cascades_projected_justification_across_rounds() { use ethlambda_types::{ block::BlockHeader, - state::{ChainConfig, JustificationValidators, JustifiedSlots}, + state::{JustificationValidators, JustifiedSlots, StateConfig}, }; use libssz_types::SszList; @@ -1723,7 +1723,7 @@ mod tests { body_root: BlockBody::default().hash_tree_root(), }; let head_state = State { - config: ChainConfig { genesis_time: 1000 }, + config: StateConfig { genesis_time: 1000 }, slot: HEAD_SLOT, latest_block_header: head_header, latest_justified: Checkpoint::default(), diff --git a/crates/blockchain/src/events.rs b/crates/blockchain/src/events.rs index ffb63f46..5977bab6 100644 --- a/crates/blockchain/src/events.rs +++ b/crates/blockchain/src/events.rs @@ -322,6 +322,7 @@ fn checkpoint_state_root(store: &Store, root: H256) -> Option { mod tests { use super::*; use ethlambda_storage::{ForkCheckpoints, backend::InMemoryBackend}; + use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT; use ethlambda_types::{ block::{Block, BlockBody, MultiMessageAggregate, SignedBlock}, state::State, @@ -435,7 +436,11 @@ mod tests { fn test_store() -> Store { let genesis_state = State::from_genesis(1000, vec![]); - Store::from_anchor_state(Arc::new(InMemoryBackend::new()), genesis_state) + Store::from_anchor_state( + Arc::new(InMemoryBackend::new()), + genesis_state, + DEFAULT_MILLISECONDS_PER_SLOT, + ) } /// Insert a header-only block at `root` so header reads (block root, slot, diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 2c8cd212..ac361681 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -10,13 +10,14 @@ use ethlambda_types::{ aggregator::AggregatorController, attestation::{SignedAggregatedAttestation, SignedAttestation}, block::{ByteList512KiB, MultiMessageAggregate, SignedBlock}, + chain_config::ChainConfig, primitives::{H256, HashTreeRoot as _}, }; use crate::aggregation::{ - AGGREGATION_DEADLINE, AggregateProduced, AggregationDeadline, AggregationDone, - AggregationSession, EARLY_AGGREGATION_WINDOW, EarlyAggregationCheck, MAX_AGGREGATION_JOBS, - PRIOR_WORKER_JOIN_TIMEOUT, run_aggregation_worker, + AggregateProduced, AggregationDeadline, AggregationDone, AggregationSession, + EarlyAggregationCheck, MAX_AGGREGATION_JOBS, PRIOR_WORKER_JOIN_TIMEOUT, aggregation_deadline, + early_aggregation_window, run_aggregation_worker, }; use crate::key_manager::ValidatorKeyPair; use crate::sync_status::SyncStatusTracker; @@ -72,15 +73,14 @@ pub struct BlockChainConfig { // derives slots from `store.time()` and must not carry a second copy of a // consensus-critical constant. pub use ethlambda_types::block::MAX_ATTESTATIONS_DATA; -pub use ethlambda_types::constants::{ - INTERVALS_PER_SLOT, MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, -}; +pub use ethlambda_types::constants::{DEFAULT_MILLISECONDS_PER_SLOT, INTERVALS_PER_SLOT}; pub use sync_status::SyncStatusController; /// Future-slot tolerance for gossip attestations, expressed in intervals. /// /// Bounds the clock skew the time check is willing to absorb when admitting a -/// vote whose slot has not yet started locally. One interval is roughly 800 ms, -/// the lean analogue of mainnet's `MAXIMUM_GOSSIP_CLOCK_DISPARITY`. +/// vote whose slot has not yet started locally. One interval is a fifth of the +/// configured slot, the lean analogue of mainnet's +/// `MAXIMUM_GOSSIP_CLOCK_DISPARITY`. /// /// See: leanSpec PR #682. pub const GOSSIP_DISPARITY_INTERVALS: u64 = 1; @@ -95,8 +95,8 @@ pub(crate) enum SlotInterval { } impl SlotInterval { - pub(crate) fn from_ms_since_genesis(ms_since_genesis: u64) -> Self { - Self::from_intervals_since_genesis(ms_since_genesis / MILLISECONDS_PER_INTERVAL) + pub(crate) fn from_ms_since_genesis(ms_since_genesis: u64, config: &ChainConfig) -> Self { + Self::from_intervals_since_genesis(ms_since_genesis / config.milliseconds_per_interval()) } pub(crate) fn from_intervals_since_genesis(intervals_since_genesis: u64) -> Self { @@ -113,7 +113,7 @@ impl SlotInterval { /// Milliseconds from genesis to the start of this interval in `slot`. /// /// Inverse of [`Self::from_ms_since_genesis`]. - pub(crate) fn to_ms_since_genesis(self, slot: u64) -> u64 { + pub(crate) fn to_ms_since_genesis(self, slot: u64, config: &ChainConfig) -> u64 { let interval = match self { Self::BlockPublication => 0, Self::AttestationProduction => 1, @@ -121,17 +121,19 @@ impl SlotInterval { Self::SafeTargetUpdate => 3, Self::EndOfSlot => 4, }; - slot * MILLISECONDS_PER_SLOT + interval * MILLISECONDS_PER_INTERVAL + slot * config.milliseconds_per_slot + interval * config.milliseconds_per_interval() } } /// Milliseconds until the next interval boundary, measured relative to genesis. -fn ms_until_next_interval(now_ms: u64, genesis_time_ms: u64) -> u64 { +fn ms_until_next_interval(now_ms: u64, config: &ChainConfig) -> u64 { + let genesis_time_ms = config.genesis_time_ms(); // Before genesis: wait until genesis itself. let Some(ms_since_genesis) = now_ms.checked_sub(genesis_time_ms) else { return genesis_time_ms - now_ms; }; - MILLISECONDS_PER_INTERVAL - (ms_since_genesis % MILLISECONDS_PER_INTERVAL) + let ms_per_interval = config.milliseconds_per_interval(); + ms_per_interval - (ms_since_genesis % ms_per_interval) } /// Current UNIX timestamp in milliseconds. @@ -164,15 +166,16 @@ impl BlockChain { metrics::set_is_aggregator(aggregator.is_enabled()); metrics::set_node_sync_status(metrics::SyncStatus::Idle); - let genesis_time = store.config().genesis_time; + let time_config = *store.config(); + let genesis_time = time_config.genesis_time; let mut key_manager = key_manager::KeyManager::new(validator_keys); // Catch XMSS keys up to the current slot before the first tick // store.time() doesn't work here: after an offline gap it lags wall-clock by // exactly the gap we need to catch up through let now_ms = unix_now_ms(); - let current_slot = - (now_ms.saturating_sub(genesis_time * 1000) / MILLISECONDS_PER_SLOT) as u32; + let current_slot = (now_ms.saturating_sub(time_config.genesis_time_ms()) + / time_config.milliseconds_per_slot) as u32; key_manager.advance_keys_to(current_slot); let handle = BlockChainServer { @@ -285,12 +288,12 @@ pub struct BlockChainServer { impl BlockChainServer { async fn on_tick(&mut self, timestamp_ms: u64, ctx: &Context) { - let genesis_time_ms = self.store.config().genesis_time * 1000; + let time_config = *self.store.config(); // Calculate current slot and interval from milliseconds - let time_since_genesis_ms = timestamp_ms.saturating_sub(genesis_time_ms); - let slot = time_since_genesis_ms / MILLISECONDS_PER_SLOT; - let interval = SlotInterval::from_ms_since_genesis(time_since_genesis_ms); + let time_since_genesis_ms = timestamp_ms.saturating_sub(time_config.genesis_time_ms()); + let slot = time_since_genesis_ms / time_config.milliseconds_per_slot; + let interval = SlotInterval::from_ms_since_genesis(time_since_genesis_ms, &time_config); // Idempotency guard // @@ -298,7 +301,7 @@ impl BlockChainServer { // by the monotonic clock (`tokio::sleep`). The wall clock can drift behind it // inside VMs, so a tick scheduled for the next interval boundary can fire // while the wall clock still reads the previous interval. - let tick_interval = time_since_genesis_ms / MILLISECONDS_PER_INTERVAL; + let tick_interval = time_since_genesis_ms / time_config.milliseconds_per_interval(); let store_time = self.store.time().expect("store time exists"); if store_time > 0 && tick_interval <= store_time { @@ -406,10 +409,12 @@ impl BlockChainServer { // Schedule the early-aggregation window check. This tick is // one interval before T2, so the timer fires right as the - // window opens at T2 - EARLY_AGGREGATION_WINDOW. + // window opens at T2 - `early_aggregation_window`. if is_aggregator { + let ms_per_interval = time_config.milliseconds_per_interval(); send_after( - Duration::from_millis(MILLISECONDS_PER_INTERVAL) - EARLY_AGGREGATION_WINDOW, + Duration::from_millis(ms_per_interval) + - early_aggregation_window(ms_per_interval), ctx.clone(), EarlyAggregationCheck, ); @@ -474,7 +479,7 @@ impl BlockChainServer { /// 2. Snapshot the aggregation inputs from the store, capped at a single job /// when we propose next slot. /// 3. Spawn a `spawn_blocking` worker that streams results back as messages. - /// 4. Schedule the `AggregationDeadline` self-message at +`AGGREGATION_DEADLINE`. + /// 4. Schedule the `AggregationDeadline` self-message at one interval out. /// /// Both entry points land here — the interval-2 tick and the early /// 2/3-threshold trigger — so the proposer cap applies to whichever one @@ -519,8 +524,9 @@ impl BlockChainServer { }; let session_id = slot; - let genesis_time_ms = self.store.config().genesis_time * 1000; - let t2_ms = genesis_time_ms + slot * MILLISECONDS_PER_SLOT + 2 * MILLISECONDS_PER_INTERVAL; + let time_config = *self.store.config(); + let t2_ms = time_config.genesis_time_ms() + + SlotInterval::Aggregation.to_ms_since_genesis(slot, &time_config); // Interval-2 boundary as a wall-clock instant; the worker holds each // produced aggregate until this before sending it back, so nothing // reaches gossip early. @@ -540,7 +546,7 @@ impl BlockChainServer { // Independent token per session. Shutdown propagates via our // #[stopped] hook which cancels any current session; the deadline - // timer cancels this specific session at +AGGREGATION_DEADLINE. + // timer cancels this specific session at +`aggregation_deadline`. let cancel = CancellationToken::new(); let actor_ref = ctx.actor_ref(); @@ -557,7 +563,7 @@ impl BlockChainServer { }); let _deadline_timer = send_after( - AGGREGATION_DEADLINE, + aggregation_deadline(time_config.milliseconds_per_interval()), ctx.clone(), AggregationDeadline { session_id }, ); @@ -571,7 +577,7 @@ impl BlockChainServer { } /// Early-aggregation trigger: start the slot's session ahead of the - /// interval-2 tick when, inside the window `[T2 - EARLY_AGGREGATION_WINDOW, T2)`, + /// interval-2 tick when, inside the window `[T2 - early_aggregation_window, T2)`, /// a single attestation-data group already holds 2/3 of the signatures /// expected from this node's aggregation subnets. Called after every /// stored current-slot gossip signature and once at the window opening via @@ -586,19 +592,21 @@ impl BlockChainServer { return; } // Only fire inside the early-aggregation window - // `[T2 - EARLY_AGGREGATION_WINDOW, T2)`, where T2 is the current + // `[T2 - early_aggregation_window, T2)`, where T2 is the current // slot's interval-2 boundary; the slot is derived from the wall clock. - let genesis_time_ms = self.store.config().genesis_time * 1000; - let Some(ms_since_genesis) = unix_now_ms().checked_sub(genesis_time_ms) else { + let time_config = *self.store.config(); + let Some(ms_since_genesis) = unix_now_ms().checked_sub(time_config.genesis_time_ms()) + else { return; }; - let ms_into_slot = ms_since_genesis % MILLISECONDS_PER_SLOT; - let t2_offset = 2 * MILLISECONDS_PER_INTERVAL; - let window_ms = EARLY_AGGREGATION_WINDOW.as_millis() as u64; + let ms_per_interval = time_config.milliseconds_per_interval(); + let ms_into_slot = ms_since_genesis % time_config.milliseconds_per_slot; + let t2_offset = 2 * ms_per_interval; + let window_ms = early_aggregation_window(ms_per_interval).as_millis() as u64; if ms_into_slot < t2_offset - window_ms || ms_into_slot >= t2_offset { return; } - let slot = ms_since_genesis / MILLISECONDS_PER_SLOT; + let slot = ms_since_genesis / time_config.milliseconds_per_slot; if self .current_aggregation .as_ref() @@ -718,8 +726,9 @@ impl BlockChainServer { async fn propose_block(&mut self, slot: u64, validator_id: u64) { info!(%slot, %validator_id, "We are the proposer for this slot"); - let genesis_time_ms = self.store.config().genesis_time * 1000; - let slot_start_ms = genesis_time_ms + slot * MILLISECONDS_PER_SLOT; + let time_config = *self.store.config(); + let slot_start_ms = + time_config.genesis_time_ms() + slot * time_config.milliseconds_per_slot; // Build the block. `produce_block_with_signatures` advances the store to // this slot's interval 0 (accepting attestations) before building — one @@ -889,7 +898,7 @@ impl BlockChainServer { // Align publication to the slot boundary. If the build finished before // the slot opened, wait out the remainder so the block is not published // early; if it overran, publish immediately. - if now_ms < genesis_time_ms + slot * crate::MILLISECONDS_PER_SLOT { + if now_ms < slot_start_ms { let wait_ms = slot_start_ms.saturating_sub(now_ms); tokio::time::sleep(Duration::from_millis(wait_ms)).await; } @@ -946,8 +955,9 @@ impl BlockChainServer { } // Block import has no ready-made "now" slot like `on_tick`'s, so // compute the wall-clock slot fresh for the head-recency gate. - let genesis_time_ms = self.store.config().genesis_time * 1000; - let wall_clock_slot = unix_now_ms().saturating_sub(genesis_time_ms) / MILLISECONDS_PER_SLOT; + let time_config = *self.store.config(); + let wall_clock_slot = unix_now_ms().saturating_sub(time_config.genesis_time_ms()) + / time_config.milliseconds_per_slot; pre_import.diff_and_emit(&self.store, &self.events, wall_clock_slot); metrics::update_head_slot(self.store.head_slot()); @@ -1317,8 +1327,8 @@ impl BlockChainServer { let now_ms = unix_now_ms(); self.on_tick(now_ms, ctx).await; - let genesis_time_ms = self.store.config().genesis_time * 1000; - let remaining_at_entry = ms_until_next_interval(now_ms, genesis_time_ms); + let time_config = *self.store.config(); + let remaining_at_entry = ms_until_next_interval(now_ms, &time_config); let now_after_tick = unix_now_ms(); let elapsed = now_after_tick.saturating_sub(now_ms); @@ -1328,7 +1338,7 @@ impl BlockChainServer { 0 } else { // Schedule the next tick at the next interval boundary - ms_until_next_interval(now_after_tick, genesis_time_ms) + ms_until_next_interval(now_after_tick, &time_config) }; send_after( Duration::from_millis(ms_to_next_interval), @@ -1393,8 +1403,7 @@ impl Handler for BlockChainServer { slot, block: msg.block.message.hash_tree_root(), }); - let genesis_ms = self.store.config().genesis_time * 1000; - metrics::observe_gossip_block_arrival(arrival_ms, genesis_ms, slot); + metrics::observe_gossip_block_arrival(arrival_ms, self.store.config(), slot); } self.on_block(msg.block); } @@ -1403,10 +1412,9 @@ impl Handler for BlockChainServer { impl Handler for BlockChainServer { async fn handle(&mut self, msg: NewAttestation, ctx: &Context) { let arrival_ms = unix_now_ms(); - let genesis_ms = self.store.config().genesis_time * 1000; metrics::observe_gossip_attestation_arrival( arrival_ms, - genesis_ms, + self.store.config(), msg.attestation.data.slot, ); self.on_gossip_attestation(&msg.attestation); @@ -1423,8 +1431,7 @@ impl Handler for BlockChainServer { impl Handler for BlockChainServer { async fn handle(&mut self, msg: NewAggregatedAttestation, _ctx: &Context) { let arrival_ms = unix_now_ms(); - let genesis_ms = self.store.config().genesis_time * 1000; - metrics::observe_gossip_aggregation_arrival(arrival_ms, genesis_ms); + metrics::observe_gossip_aggregation_arrival(arrival_ms, self.store.config()); self.on_gossip_aggregated_attestation(msg.attestation); } } @@ -1458,8 +1465,7 @@ impl Handler for BlockChainServer { // and costs little in practice: a late aggregate is late for every node // at once, so both populations are dominated by production time rather // than propagation and their distributions look alike. - let genesis_ms = self.store.config().genesis_time * 1000; - metrics::observe_gossip_aggregation_arrival(arrival_ms, genesis_ms); + metrics::observe_gossip_aggregation_arrival(arrival_ms, self.store.config()); // Publish alignment is enforced upstream: the worker delays delivery of // this message until the interval-2 boundary, so by the time it lands @@ -1511,7 +1517,9 @@ impl Handler for BlockChainServer { total_children = msg.total_children, cancelled = msg.cancelled, early, - aggregation_deadline_ms = AGGREGATION_DEADLINE.as_millis() as u64, + aggregation_deadline_ms = + aggregation_deadline(self.store.config().milliseconds_per_interval()).as_millis() + as u64, "Committee signatures aggregated" ); } @@ -1526,3 +1534,100 @@ impl Handler for BlockChainServer { } } } + +#[cfg(test)] +mod tests { + use super::*; + + const GENESIS_TIME: u64 = 1_000; + + fn config(milliseconds_per_slot: u64) -> ChainConfig { + ChainConfig::new(GENESIS_TIME, milliseconds_per_slot) + } + + #[test] + fn interval_boundaries_scale_with_the_slot_duration() { + let default = config(DEFAULT_MILLISECONDS_PER_SLOT); + let doubled = config(2 * DEFAULT_MILLISECONDS_PER_SLOT); + + // Same interval index, twice the offset. + for interval in [ + SlotInterval::BlockPublication, + SlotInterval::AttestationProduction, + SlotInterval::Aggregation, + SlotInterval::SafeTargetUpdate, + SlotInterval::EndOfSlot, + ] { + let at_default = interval.to_ms_since_genesis(7, &default); + assert_eq!(interval.to_ms_since_genesis(7, &doubled), 2 * at_default); + } + } + + #[test] + fn interval_conversions_round_trip() { + let config = config(8_000); + + for slot in [0, 1, 42] { + for interval in [ + SlotInterval::BlockPublication, + SlotInterval::AttestationProduction, + SlotInterval::Aggregation, + SlotInterval::SafeTargetUpdate, + SlotInterval::EndOfSlot, + ] { + let start = interval.to_ms_since_genesis(slot, &config); + assert_eq!( + SlotInterval::from_ms_since_genesis(start, &config), + interval + ); + // Still the same interval one millisecond before the next boundary. + let last_ms = start + config.milliseconds_per_interval() - 1; + assert_eq!( + SlotInterval::from_ms_since_genesis(last_ms, &config), + interval + ); + } + } + } + + #[test] + fn next_interval_is_a_full_interval_away_at_a_boundary() { + let config = config(8_000); + let genesis_ms = config.genesis_time_ms(); + + assert_eq!(ms_until_next_interval(genesis_ms, &config), 1_600); + assert_eq!(ms_until_next_interval(genesis_ms + 1, &config), 1_599); + assert_eq!(ms_until_next_interval(genesis_ms + 1_599, &config), 1); + assert_eq!(ms_until_next_interval(genesis_ms + 1_600, &config), 1_600); + } + + #[test] + fn before_genesis_the_next_tick_is_genesis_itself() { + let config = config(8_000); + let genesis_ms = config.genesis_time_ms(); + + assert_eq!(ms_until_next_interval(genesis_ms - 500, &config), 500); + } + + /// The window is subtracted from an interval offset in two places, so it + /// must never exceed one interval however short the configured slot is. + #[test] + fn early_aggregation_window_never_exceeds_one_interval() { + for milliseconds_per_slot in [1_000, 2_000, DEFAULT_MILLISECONDS_PER_SLOT, 8_000] { + let ms_per_interval = config(milliseconds_per_slot).milliseconds_per_interval(); + assert!( + early_aggregation_window(ms_per_interval).as_millis() as u64 <= ms_per_interval, + "window overflows the interval at {milliseconds_per_slot} ms per slot" + ); + } + } + + #[test] + fn aggregation_deadline_is_one_interval() { + let config = config(8_000); + assert_eq!( + aggregation_deadline(config.milliseconds_per_interval()), + Duration::from_millis(1_600) + ); + } +} diff --git a/crates/blockchain/src/metrics.rs b/crates/blockchain/src/metrics.rs index 81d00c29..7cd8f5d9 100644 --- a/crates/blockchain/src/metrics.rs +++ b/crates/blockchain/src/metrics.rs @@ -3,6 +3,7 @@ use std::time::Duration; use ethlambda_metrics::*; +use ethlambda_types::chain_config::ChainConfig; // --- Label sets --- @@ -426,6 +427,10 @@ static LEAN_AGGREGATION_EARLY_START_LEAD_SECONDS: std::sync::LazyLock .unwrap() }); +/// Buckets clustered just past 0.8 s, the interval width of the default +/// 4-second cadence ([`crate::DEFAULT_MILLISECONDS_PER_SLOT`]). Prometheus +/// fixes buckets at registration, so a network on a different slot duration +/// reads this histogram against the default grid rather than its own. static LEAN_TICK_INTERVAL_DURATION_SECONDS: std::sync::LazyLock = std::sync::LazyLock::new(|| { register_histogram!( @@ -545,9 +550,14 @@ static LEAN_BLOCK_PROPOSAL_AGGREGATES_SELECTED: std::sync::LazyLock = // --- Gossip Arrival Timing --- -/// Bucket boundaries shared by the three gossip arrival-delay histograms, -/// aligned to the interval and slot durations (see -/// [`crate::MILLISECONDS_PER_INTERVAL`], [`crate::MILLISECONDS_PER_SLOT`]). +/// Bucket boundaries shared by the three gossip arrival-delay histograms. +/// +/// Fixed rather than derived from the configured slot duration: Prometheus +/// bakes buckets in at registration, and these are the interval and slot +/// boundaries of the default 4-second cadence +/// ([`crate::DEFAULT_MILLISECONDS_PER_SLOT`]). A network running a different +/// cadence still gets usable buckets, just ones that no longer line up with +/// its interval edges. fn gossip_arrival_delay_buckets() -> Vec { vec![0.05, 0.1, 0.2, 0.4, 0.8, 1.2, 1.6, 2.4, 4.0, 8.0, 16.0] } @@ -624,34 +634,34 @@ static LEAN_GOSSIP_AGGREGATION_ARRIVAL_TOTAL: std::sync::LazyLock /// `arrival_ms`. Negative means the message arrived before it was due. fn interval_delta_ms( arrival_ms: u64, - genesis_ms: u64, + config: &ChainConfig, anchor_slot: u64, interval: crate::SlotInterval, ) -> i64 { - let expected_ms = genesis_ms + interval.to_ms_since_genesis(anchor_slot); + let expected_ms = config.genesis_time_ms() + interval.to_ms_since_genesis(anchor_slot, config); arrival_ms as i64 - expected_ms as i64 } /// Milliseconds since the most recent `interval` boundary at or before -/// `arrival_ms`. Always in `[0, MILLISECONDS_PER_SLOT)`, so it never reports +/// `arrival_ms`. Always in `[0, milliseconds_per_slot)`, so it never reports /// a negative delta. fn latest_interval_delta_ms( arrival_ms: u64, - genesis_ms: u64, + config: &ChainConfig, interval: crate::SlotInterval, ) -> i64 { - let since_genesis = arrival_ms.saturating_sub(genesis_ms) as i64; + let since_genesis = arrival_ms.saturating_sub(config.genesis_time_ms()) as i64; // Slot 0 makes `to_ms_since_genesis` yield just the offset within a slot. - let anchor_offset = interval.to_ms_since_genesis(0) as i64; - (since_genesis - anchor_offset).rem_euclid(crate::MILLISECONDS_PER_SLOT as i64) + let anchor_offset = interval.to_ms_since_genesis(0, config) as i64; + (since_genesis - anchor_offset).rem_euclid(config.milliseconds_per_slot as i64) } /// Classify a signed delta against the interval width: `inside` is the /// half-open range from the interval's start up to its end. -fn position_from_delta(delta_ms: i64) -> SlotPosition { +fn position_from_delta(delta_ms: i64, config: &ChainConfig) -> SlotPosition { if delta_ms < 0 { SlotPosition::Before - } else if delta_ms < crate::MILLISECONDS_PER_INTERVAL as i64 { + } else if delta_ms < config.milliseconds_per_interval() as i64 { SlotPosition::Inside } else { SlotPosition::After @@ -661,34 +671,34 @@ fn position_from_delta(delta_ms: i64) -> SlotPosition { /// Observe a gossip block's arrival against the start of its own slot's /// [`crate::SlotInterval::BlockPublication`] interval. Zero point: `block_slot`'s /// slot boundary. -pub fn observe_gossip_block_arrival(arrival_ms: u64, genesis_ms: u64, block_slot: u64) { +pub fn observe_gossip_block_arrival(arrival_ms: u64, config: &ChainConfig, block_slot: u64) { let delta_ms = interval_delta_ms( arrival_ms, - genesis_ms, + config, block_slot, crate::SlotInterval::BlockPublication, ); LEAN_GOSSIP_BLOCK_ARRIVAL_DELAY_SECONDS .observe(Duration::from_millis(delta_ms.unsigned_abs()).as_secs_f64()); LEAN_GOSSIP_BLOCK_ARRIVAL_TOTAL - .with_label_values(&[position_from_delta(delta_ms).as_str()]) + .with_label_values(&[position_from_delta(delta_ms, config).as_str()]) .inc(); } /// Observe a gossip attestation's arrival against its data slot's /// [`crate::SlotInterval::AttestationProduction`] interval. Zero point: /// `data_slot`'s interval-1 boundary. -pub fn observe_gossip_attestation_arrival(arrival_ms: u64, genesis_ms: u64, data_slot: u64) { +pub fn observe_gossip_attestation_arrival(arrival_ms: u64, config: &ChainConfig, data_slot: u64) { let delta_ms = interval_delta_ms( arrival_ms, - genesis_ms, + config, data_slot, crate::SlotInterval::AttestationProduction, ); LEAN_GOSSIP_ATTESTATION_ARRIVAL_DELAY_SECONDS .observe(Duration::from_millis(delta_ms.unsigned_abs()).as_secs_f64()); LEAN_GOSSIP_ATTESTATION_ARRIVAL_TOTAL - .with_label_values(&[position_from_delta(delta_ms).as_str()]) + .with_label_values(&[position_from_delta(delta_ms, config).as_str()]) .inc(); } @@ -701,13 +711,12 @@ pub fn observe_gossip_attestation_arrival(arrival_ms: u64, genesis_ms: u64, data /// `aggregation.rs`), so anchoring to `data.slot` would fill the histogram /// with large values that are not a health problem. Assuming the latest /// aggregation interval bounds the value to one slot. -pub fn observe_gossip_aggregation_arrival(arrival_ms: u64, genesis_ms: u64) { - let delta_ms = - latest_interval_delta_ms(arrival_ms, genesis_ms, crate::SlotInterval::Aggregation); +pub fn observe_gossip_aggregation_arrival(arrival_ms: u64, config: &ChainConfig) { + let delta_ms = latest_interval_delta_ms(arrival_ms, config, crate::SlotInterval::Aggregation); LEAN_GOSSIP_AGGREGATION_ARRIVAL_DELAY_SECONDS .observe(Duration::from_millis(delta_ms.unsigned_abs()).as_secs_f64()); LEAN_GOSSIP_AGGREGATION_ARRIVAL_TOTAL - .with_label_values(&[position_from_delta(delta_ms).as_str()]) + .with_label_values(&[position_from_delta(delta_ms, config).as_str()]) .inc(); } diff --git a/crates/blockchain/src/reaggregate.rs b/crates/blockchain/src/reaggregate.rs index 64e762e7..13b433c5 100644 --- a/crates/blockchain/src/reaggregate.rs +++ b/crates/blockchain/src/reaggregate.rs @@ -281,6 +281,7 @@ fn select_candidates(store: &Store, attestations: &[AggregatedAttestation]) -> V mod tests { use super::*; use ethlambda_storage::{Store, backend::InMemoryBackend}; + use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT; use ethlambda_types::{ attestation::{AggregatedAttestation, AggregationBits, AttestationData}, checkpoint::Checkpoint, @@ -314,7 +315,11 @@ mod tests { fn empty_store() -> Store { let backend: Arc = Arc::new(InMemoryBackend::new()); - Store::from_anchor_state(backend, State::from_genesis(0, vec![])) + Store::from_anchor_state( + backend, + State::from_genesis(0, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ) } #[test] diff --git a/crates/blockchain/src/spec_test_runner.rs b/crates/blockchain/src/spec_test_runner.rs index c3ab67af..cc2bebc2 100644 --- a/crates/blockchain/src/spec_test_runner.rs +++ b/crates/blockchain/src/spec_test_runner.rs @@ -12,10 +12,7 @@ use ethlambda_types::{ block::{ByteList512KiB, SingleMessageAggregate}, }; -use crate::{ - MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, - store::{self, StoreError}, -}; +use crate::store::{self, StoreError}; /// Prefix emitted by leanSpec's mocked aggregation prover. const MOCK_PROOF_PREFIX: &[u8] = b"\x00MOCKED-AGGREGATION-PROOF\x00"; @@ -114,11 +111,11 @@ pub fn apply_fork_choice_step( ) -> Result<(), StepError> { match step.step_type.as_str() { "tick" => { - let genesis_time = store.config().genesis_time; + let config = *store.config(); let timestamp_ms = match (step.time, step.interval) { (Some(time_s), _) => time_s * 1000, (None, Some(interval)) => { - genesis_time * 1000 + interval * MILLISECONDS_PER_INTERVAL + config.genesis_time_ms() + interval * config.milliseconds_per_interval() } (None, None) => { return Err(StepError::Harness( @@ -136,8 +133,8 @@ pub fn apply_fork_choice_step( .ok_or_else(|| StepError::Harness("block step missing block data".to_string()))?; let signed_block = block_data.to_blank_signed_block(); if step.tick_to_slot { - let block_time_ms = store.config().genesis_time * 1000 - + signed_block.message.slot * MILLISECONDS_PER_SLOT; + let block_time_ms = store.config().genesis_time_ms() + + signed_block.message.slot * store.config().milliseconds_per_slot; store::on_tick(store, block_time_ms, true); } store::on_block_without_verification(store, signed_block)?; diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 2898fcd4..216c322c 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -17,8 +17,7 @@ use ethlambda_types::{ use tracing::{info, trace, warn}; use crate::{ - GOSSIP_DISPARITY_INTERVALS, INTERVALS_PER_SLOT, MAX_ATTESTATIONS_DATA, - MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, SlotInterval, + GOSSIP_DISPARITY_INTERVALS, INTERVALS_PER_SLOT, MAX_ATTESTATIONS_DATA, SlotInterval, block_builder::{PostBlockCheckpoints, ProposerConfig, build_block}, metrics, }; @@ -323,14 +322,14 @@ fn validate_attestation_data(store: &Store, data: &AttestationData) -> Result<() /// Process a tick event. /// /// `store.time()` represents interval-count-since-genesis: each increment is one -/// 800ms interval. Slot and interval-within-slot are derived as: +/// interval, a fifth of the configured slot. Slot and interval-within-slot are +/// derived as: /// slot = store.time() / INTERVALS_PER_SLOT /// interval = store.time() % INTERVALS_PER_SLOT pub fn on_tick(store: &mut Store, timestamp_ms: u64, has_proposal: bool) { // Convert UNIX timestamp (ms) to interval count since genesis - let genesis_time_ms = store.config().genesis_time * 1000; - let time_delta_ms = timestamp_ms.saturating_sub(genesis_time_ms); - let time = time_delta_ms / MILLISECONDS_PER_INTERVAL; + let time_delta_ms = timestamp_ms.saturating_sub(store.config().genesis_time_ms()); + let time = time_delta_ms / store.config().milliseconds_per_interval(); // If we're more than a slot behind, fast-forward to a slot before. // Operations are idempotent, so this should be fine. @@ -886,7 +885,8 @@ pub fn produce_attestation_data(store: &Store, slot: u64) -> AttestationData { /// before returning the canonical head. fn get_proposal_head(store: &mut Store, slot: u64) -> H256 { // Calculate time corresponding to this slot - let slot_time_ms = store.config().genesis_time * 1000 + slot * MILLISECONDS_PER_SLOT; + let slot_time_ms = + store.config().genesis_time_ms() + slot * store.config().milliseconds_per_slot; // Advance time to current slot (ticking intervals) on_tick(store, slot_time_ms, true); @@ -1277,6 +1277,7 @@ fn reorg_depth(old_head: H256, new_head: H256, store: &Store) -> Option { #[cfg(test)] mod tests { use super::*; + use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT; use ethlambda_types::{ attestation::{AggregatedAttestation, AggregationBits, AttestationData}, block::{ @@ -1309,6 +1310,35 @@ mod tests { bits } + /// The store clock counts intervals, so it has to advance once per + /// configured interval rather than once per hardcoded 800 ms. + #[test] + fn on_tick_advances_one_interval_per_configured_interval() { + use ethlambda_storage::backend::InMemoryBackend; + use std::sync::Arc; + + const GENESIS_TIME: u64 = 1_000; + const MILLISECONDS_PER_SLOT: u64 = 8_000; + + let backend = Arc::new(InMemoryBackend::new()); + let genesis_state = State::from_genesis(GENESIS_TIME, vec![]); + let mut store = Store::from_anchor_state(backend, genesis_state, MILLISECONDS_PER_SLOT); + let genesis_ms = GENESIS_TIME * 1_000; + + // One interval in: still short of the second boundary at 1600 ms. + on_tick(&mut store, genesis_ms + 1_599, false); + assert_eq!(store.time().unwrap(), 0); + + on_tick(&mut store, genesis_ms + 1_600, false); + assert_eq!(store.time().unwrap(), 1); + assert_eq!(store.current_slot(), 0); + + // A whole slot in: five intervals, so the slot rolls over. + on_tick(&mut store, genesis_ms + MILLISECONDS_PER_SLOT, false); + assert_eq!(store.time().unwrap(), INTERVALS_PER_SLOT); + assert_eq!(store.current_slot(), 1); + } + #[test] fn on_block_rejects_duplicate_attestation_data() { use ethlambda_storage::backend::InMemoryBackend; @@ -1319,7 +1349,8 @@ mod tests { // Use `from_anchor_state` here rather than `get_forkchoice_store`: // the latter now enforces `block.state_root == hash_tree_root(state)`, // which a synthetic genesis block with zero state_root cannot satisfy. - let mut store = Store::from_anchor_state(backend, genesis_state); + let mut store = + Store::from_anchor_state(backend, genesis_state, DEFAULT_MILLISECONDS_PER_SLOT); let head_root = store.head().expect("store head exists"); let att_data = AttestationData { @@ -1414,7 +1445,7 @@ mod tests { use std::sync::Arc; let genesis_state = State::from_genesis(1000, vec![]); let backend = Arc::new(InMemoryBackend::new()); - Store::from_anchor_state(backend, genesis_state) + Store::from_anchor_state(backend, genesis_state, DEFAULT_MILLISECONDS_PER_SLOT) } /// The produced attestation source must track the head state's justified @@ -1760,7 +1791,8 @@ mod tests { let genesis_state = State::from_genesis(1000, vec![]); let backend = Arc::new(InMemoryBackend::new()); - let mut store = Store::from_anchor_state(backend, genesis_state); + let mut store = + Store::from_anchor_state(backend, genesis_state, DEFAULT_MILLISECONDS_PER_SLOT); store.set_time(0).expect("set_time should succeed"); // current_slot = 0, so the horizon is slot 1; a slot-2 block overshoots it. @@ -1800,7 +1832,8 @@ mod tests { let genesis_state = State::from_genesis(1000, vec![]); let backend = Arc::new(InMemoryBackend::new()); - let mut store = Store::from_anchor_state(backend, genesis_state); + let mut store = + Store::from_anchor_state(backend, genesis_state, DEFAULT_MILLISECONDS_PER_SLOT); store.set_time(0).expect("set_time should succeed"); // Parent (genesis) sits at slot 0, so a slot one past the limit overshoots. diff --git a/crates/blockchain/state_transition/src/lib.rs b/crates/blockchain/state_transition/src/lib.rs index d53b089b..d03f74b2 100644 --- a/crates/blockchain/state_transition/src/lib.rs +++ b/crates/blockchain/state_transition/src/lib.rs @@ -651,7 +651,7 @@ mod tests { block::BlockBody, checkpoint::Checkpoint, primitives::H256, - state::{ChainConfig, JustifiedSlots, State, Validator}, + state::{JustifiedSlots, State, StateConfig, Validator}, }; use libssz_types::SszList; @@ -807,7 +807,7 @@ mod tests { justified_slots_ops::set_justified(&mut justified_slots, 0, 3); let mut state = State { - config: ChainConfig { genesis_time: 0 }, + config: StateConfig { genesis_time: 0 }, slot: 10, latest_block_header: BlockHeader { slot: 9, @@ -878,7 +878,7 @@ mod tests { justified_slots_ops::extend_to_slot(&mut justified_slots, 4, 6); let mut state = State { - config: ChainConfig { genesis_time: 0 }, + config: StateConfig { genesis_time: 0 }, slot: 7, latest_block_header: BlockHeader { slot: 6, @@ -931,7 +931,7 @@ mod tests { let r1 = H256([1u8; 32]); let mut state = State { - config: ChainConfig { genesis_time: 0 }, + config: StateConfig { genesis_time: 0 }, slot: 2, latest_block_header: BlockHeader { slot: 1, @@ -977,7 +977,7 @@ mod tests { #[test] fn process_attestations_rejects_empty_validator_registry() { let mut state = State { - config: ChainConfig { genesis_time: 0 }, + config: StateConfig { genesis_time: 0 }, slot: 1, latest_block_header: BlockHeader { slot: 0, diff --git a/crates/blockchain/tests/forkchoice_spectests.rs b/crates/blockchain/tests/forkchoice_spectests.rs index c4f124a0..d9f49255 100644 --- a/crates/blockchain/tests/forkchoice_spectests.rs +++ b/crates/blockchain/tests/forkchoice_spectests.rs @@ -12,6 +12,7 @@ use ethlambda_storage::{Store, backend::InMemoryBackend}; use ethlambda_types::{ attestation::{AttestationData, validator_indices}, block::Block, + constants::DEFAULT_MILLISECONDS_PER_SLOT, primitives::{H256, HashTreeRoot as _}, state::{State, anchor_pair_is_consistent}, }; @@ -105,8 +106,13 @@ fn run(path: &Path) -> datatest_stable::Result<()> { let anchor_root = anchor_block.hash_tree_root(); let backend = Arc::new(InMemoryBackend::new()); - let mut store = Store::get_forkchoice_store(backend, anchor_state, anchor_block) - .expect("anchor state and block must match"); + let mut store = Store::get_forkchoice_store( + backend, + anchor_state, + anchor_block, + DEFAULT_MILLISECONDS_PER_SLOT, + ) + .expect("anchor state and block must match"); // Block registry: maps block labels to their roots let mut block_registry: HashMap = HashMap::new(); diff --git a/crates/blockchain/tests/signature_spectests.rs b/crates/blockchain/tests/signature_spectests.rs index 6489fb86..fa727d1e 100644 --- a/crates/blockchain/tests/signature_spectests.rs +++ b/crates/blockchain/tests/signature_spectests.rs @@ -1,10 +1,11 @@ use std::path::Path; use std::sync::Arc; -use ethlambda_blockchain::{MILLISECONDS_PER_SLOT, spec_test_runner, store}; +use ethlambda_blockchain::{spec_test_runner, store}; use ethlambda_storage::{Store, backend::InMemoryBackend}; use ethlambda_types::{ block::{Block, SignedBlock}, + constants::DEFAULT_MILLISECONDS_PER_SLOT, primitives::HashTreeRoot as _, state::State, }; @@ -58,14 +59,20 @@ fn run(path: &Path) -> datatest_stable::Result<()> { // Initialize the store with the anchor state and block let genesis_time = anchor_state.config.genesis_time; let backend = Arc::new(InMemoryBackend::new()); - let mut st = Store::get_forkchoice_store(backend, anchor_state, anchor_block) - .expect("anchor state and block must match"); + let mut st = Store::get_forkchoice_store( + backend, + anchor_state, + anchor_block, + DEFAULT_MILLISECONDS_PER_SLOT, + ) + .expect("anchor state and block must match"); // Step 2: Run the state transition function with the block fixture let signed_block: SignedBlock = test.signed_block.into(); // Advance time to the block's slot - let block_time_ms = genesis_time * 1000 + signed_block.message.slot * MILLISECONDS_PER_SLOT; + let block_time_ms = + genesis_time * 1000 + signed_block.message.slot * st.config().milliseconds_per_slot; store::on_tick(&mut st, block_time_ms, true); // Process the block (this includes signature verification) diff --git a/crates/common/test-fixtures/src/common.rs b/crates/common/test-fixtures/src/common.rs index 6705292b..d4b2c2a9 100644 --- a/crates/common/test-fixtures/src/common.rs +++ b/crates/common/test-fixtures/src/common.rs @@ -8,7 +8,7 @@ use ethlambda_types::{ checkpoint::Checkpoint as DomainCheckpoint, primitives::H256, state::{ - ChainConfig, JustificationValidators, JustifiedSlots, State, Validator as DomainValidator, + JustificationValidators, JustifiedSlots, State, StateConfig, Validator as DomainValidator, ValidatorPubkeyBytes, }, }; @@ -36,9 +36,9 @@ pub struct Config { pub genesis_time: u64, } -impl From for ChainConfig { +impl From for StateConfig { fn from(value: Config) -> Self { - ChainConfig { + StateConfig { genesis_time: value.genesis_time, } } diff --git a/crates/common/types/src/chain_config.rs b/crates/common/types/src/chain_config.rs new file mode 100644 index 00000000..ebf53186 --- /dev/null +++ b/crates/common/types/src/chain_config.rs @@ -0,0 +1,107 @@ +//! The node's own view of the chain's time grid. + +use libssz::{DecodeError, SszDecode as _}; +use libssz_derive::{SszDecode, SszEncode}; + +use crate::constants::{DEFAULT_MILLISECONDS_PER_SLOT, INTERVALS_PER_SLOT}; +use crate::state::StateConfig; + +/// Genesis time plus slot duration: everything needed to turn a wall-clock +/// reading into a slot and an interval within it. +/// +/// A superset of [`StateConfig`], deliberately kept as a separate type. The +/// SSZ [`StateConfig`] is merkleized into [`crate::state::State`]'s hash tree +/// root, so its layout is fixed by the spec and cannot gain a field; the slot +/// duration is a launch parameter read from the network's config file. Keeping +/// the two apart means `state.config` stays byte-compatible with every other +/// client while this type carries what the node actually needs to schedule +/// duties. +/// +/// Persisted in the storage backend's metadata table, where it doubles as the +/// data directory's network fingerprint: a config file whose slot duration +/// disagrees with the persisted one describes a different chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, SszEncode, SszDecode)] +pub struct ChainConfig { + /// UNIX timestamp in seconds at which slot 0 begins. + pub genesis_time: u64, + /// Slot duration in milliseconds. Always a multiple of + /// [`INTERVALS_PER_SLOT`], enforced when the config file is parsed. + pub milliseconds_per_slot: u64, +} + +impl ChainConfig { + pub fn new(genesis_time: u64, milliseconds_per_slot: u64) -> Self { + Self { + genesis_time, + milliseconds_per_slot, + } + } + + /// Genesis as a millisecond timestamp, the zero point every tick + /// computation measures from. + pub fn genesis_time_ms(&self) -> u64 { + self.genesis_time * 1_000 + } + + /// Interval duration in milliseconds. + /// + /// Exact because [`crate::genesis::GenesisConfig`] rejects a slot duration + /// that is not a multiple of [`INTERVALS_PER_SLOT`]. + pub fn milliseconds_per_interval(&self) -> u64 { + self.milliseconds_per_slot / INTERVALS_PER_SLOT + } + + /// Decode a persisted config, accepting the layout written before + /// `milliseconds_per_slot` existed. + /// + /// The legacy blob is a bare SSZ [`StateConfig`]: `genesis_time` alone. + /// Every chain that wrote one ran the compile-time 4-second cadence, so + /// filling in [`DEFAULT_MILLISECONDS_PER_SLOT`] reconstructs it exactly + /// and a data directory written by an older build stays resumable. + pub fn from_persisted_ssz_bytes(bytes: &[u8]) -> Result { + Self::from_ssz_bytes(bytes).or_else(|err| { + let legacy = StateConfig::from_ssz_bytes(bytes).map_err(|_| err)?; + Ok(Self::new( + legacy.genesis_time, + DEFAULT_MILLISECONDS_PER_SLOT, + )) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use libssz::SszEncode as _; + + #[test] + fn derives_interval_from_slot_duration() { + let config = ChainConfig::new(1_000, 8_000); + assert_eq!(config.genesis_time_ms(), 1_000_000); + assert_eq!(config.milliseconds_per_interval(), 1_600); + } + + #[test] + fn round_trips_through_ssz() { + let config = ChainConfig::new(1_770_407_233, 8_000); + let decoded = ChainConfig::from_persisted_ssz_bytes(&config.to_ssz()).unwrap(); + assert_eq!(decoded, config); + } + + #[test] + fn reads_legacy_config_as_the_default_cadence() { + let legacy = StateConfig { + genesis_time: 1_770_407_233, + }; + let decoded = ChainConfig::from_persisted_ssz_bytes(&legacy.to_ssz()).unwrap(); + assert_eq!( + decoded, + ChainConfig::new(legacy.genesis_time, DEFAULT_MILLISECONDS_PER_SLOT) + ); + } + + #[test] + fn rejects_a_blob_that_is_neither_layout() { + assert!(ChainConfig::from_persisted_ssz_bytes(&[0u8; 3]).is_err()); + } +} diff --git a/crates/common/types/src/constants.rs b/crates/common/types/src/constants.rs index c3434d9a..ec911713 100644 --- a/crates/common/types/src/constants.rs +++ b/crates/common/types/src/constants.rs @@ -9,9 +9,17 @@ // TODO: derive dynamically once the spec defines fork identification. pub const FORK_DIGEST: &str = "12345678"; -/// Milliseconds per interval (800ms ticks). -pub const MILLISECONDS_PER_INTERVAL: u64 = 800; -/// Number of intervals per slot (5 intervals of 800ms = 4 seconds). +/// Number of intervals per slot. +/// +/// Fixed rather than configurable: each interval carries a distinct validator +/// duty (see `SlotInterval` in `ethlambda-blockchain`), so the count is part of +/// the protocol rather than a tuning knob. The slot duration is configurable; +/// see [`crate::chain_config::ChainConfig`]. pub const INTERVALS_PER_SLOT: u64 = 5; -/// Milliseconds in a slot (derived from interval duration and count). -pub const MILLISECONDS_PER_SLOT: u64 = MILLISECONDS_PER_INTERVAL * INTERVALS_PER_SLOT; + +/// Slot duration used when the network's config file omits +/// `MILLISECONDS_PER_SLOT`. +/// +/// Matches the spec's `SECONDS_PER_SLOT = 4`, so a config file written before +/// the key existed keeps the cadence it was running. +pub const DEFAULT_MILLISECONDS_PER_SLOT: u64 = 4_000; diff --git a/crates/common/types/src/genesis.rs b/crates/common/types/src/genesis.rs index 239de125..d7460782 100644 --- a/crates/common/types/src/genesis.rs +++ b/crates/common/types/src/genesis.rs @@ -1,5 +1,7 @@ use serde::Deserialize; +use crate::chain_config::ChainConfig; +use crate::constants::{DEFAULT_MILLISECONDS_PER_SLOT, INTERVALS_PER_SLOT}; use crate::state::{State, Validator, ValidatorPubkeyBytes}; /// Ways a state can fail to belong to the configured genesis. @@ -11,6 +13,8 @@ use crate::state::{State, Validator, ValidatorPubkeyBytes}; pub enum GenesisMismatch { #[error("genesis time mismatch: expected {expected}, got {got}")] GenesisTime { expected: u64, got: u64 }, + #[error("slot duration mismatch: expected {expected} ms, got {got} ms")] + SlotDuration { expected: u64, got: u64 }, #[error("validator count mismatch: expected {expected}, got {got}")] ValidatorCount { expected: usize, got: usize }, #[error( @@ -34,6 +38,19 @@ pub struct GenesisValidatorEntry { pub struct GenesisConfig { #[serde(rename = "GENESIS_TIME")] pub genesis_time: u64, + /// Slot duration in milliseconds, shared by every node on the network. + /// + /// Optional: a config file that omits the key gets + /// [`DEFAULT_MILLISECONDS_PER_SLOT`], which is what the whole network ran + /// on before the key existed. Other clients still hold the value at + /// compile time and ignore unknown keys, so setting it only has an effect + /// on a network where every node reads it. + #[serde( + rename = "MILLISECONDS_PER_SLOT", + default = "default_milliseconds_per_slot", + deserialize_with = "deser_milliseconds_per_slot" + )] + pub milliseconds_per_slot: u64, #[serde(rename = "GENESIS_VALIDATORS")] pub genesis_validators: Vec, } @@ -66,6 +83,53 @@ impl GenesisConfig { pub fn verify_state(&self, state: &State) -> Result<(), GenesisMismatch> { verify_state_genesis(state, self.genesis_time, &self.validators()) } + + /// Verify a persisted [`ChainConfig`] belongs to this network. + /// + /// Complements [`Self::verify_state`], which cannot see the slot duration: + /// it is absent from the SSZ state on purpose. A data directory built at a + /// different cadence carries blocks whose slot numbers mean something else, + /// so it is as foreign as one from a different genesis. + pub fn verify_time_config(&self, persisted: &ChainConfig) -> Result<(), GenesisMismatch> { + if persisted.genesis_time != self.genesis_time { + return Err(GenesisMismatch::GenesisTime { + expected: self.genesis_time, + got: persisted.genesis_time, + }); + } + if persisted.milliseconds_per_slot != self.milliseconds_per_slot { + return Err(GenesisMismatch::SlotDuration { + expected: self.milliseconds_per_slot, + got: persisted.milliseconds_per_slot, + }); + } + Ok(()) + } +} + +fn default_milliseconds_per_slot() -> u64 { + DEFAULT_MILLISECONDS_PER_SLOT +} + +/// Parse `MILLISECONDS_PER_SLOT`, rejecting values the interval grid cannot +/// represent. +/// +/// A slot is cut into [`INTERVALS_PER_SLOT`] equal intervals and every duty is +/// scheduled off that boundary, so a duration that does not divide evenly would +/// leave the last interval short and drift the grid against the wall clock. +fn deser_milliseconds_per_slot<'de, D>(d: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error; + + let ms = u64::deserialize(d)?; + if ms == 0 || ms % INTERVALS_PER_SLOT != 0 { + return Err(D::Error::custom(format!( + "MILLISECONDS_PER_SLOT is {ms}; expected a positive multiple of {INTERVALS_PER_SLOT}" + ))); + } + Ok(ms) } /// Verify `state` was produced by the genesis described by `genesis_time` and @@ -316,4 +380,70 @@ GENESIS_VALIDATORS: }) ); } + + #[test] + fn slot_duration_defaults_when_the_key_is_absent() { + let config = test_config(); + assert_eq!(config.milliseconds_per_slot, DEFAULT_MILLISECONDS_PER_SLOT); + } + + #[test] + fn slot_duration_is_read_from_the_config_file() { + let yaml = format!("MILLISECONDS_PER_SLOT: 8000\n{TEST_CONFIG_YAML}"); + let config: GenesisConfig = serde_yaml_ng::from_str(&yaml).unwrap(); + + assert_eq!(config.milliseconds_per_slot, 8_000); + } + + #[test] + fn slot_duration_must_divide_into_whole_intervals() { + let yaml = format!("MILLISECONDS_PER_SLOT: 8001\n{TEST_CONFIG_YAML}"); + let err = serde_yaml_ng::from_str::(&yaml).unwrap_err(); + + assert!( + err.to_string().contains("MILLISECONDS_PER_SLOT is 8001"), + "unexpected error: {err}" + ); + } + + #[test] + fn slot_duration_must_be_positive() { + let yaml = format!("MILLISECONDS_PER_SLOT: 0\n{TEST_CONFIG_YAML}"); + assert!(serde_yaml_ng::from_str::(&yaml).is_err()); + } + + #[test] + fn verify_time_config_accepts_the_config_it_describes() { + let config = test_config(); + let persisted = ChainConfig::new(config.genesis_time, config.milliseconds_per_slot); + assert_eq!(config.verify_time_config(&persisted), Ok(())); + } + + #[test] + fn verify_time_config_rejects_a_different_slot_duration() { + let config = test_config(); + let persisted = ChainConfig::new(config.genesis_time, 8_000); + + assert_eq!( + config.verify_time_config(&persisted), + Err(GenesisMismatch::SlotDuration { + expected: DEFAULT_MILLISECONDS_PER_SLOT, + got: 8_000, + }) + ); + } + + #[test] + fn verify_time_config_rejects_a_different_genesis_time() { + let config = test_config(); + let persisted = ChainConfig::new(config.genesis_time + 1, config.milliseconds_per_slot); + + assert_eq!( + config.verify_time_config(&persisted), + Err(GenesisMismatch::GenesisTime { + expected: config.genesis_time, + got: config.genesis_time + 1, + }) + ); + } } diff --git a/crates/common/types/src/lib.rs b/crates/common/types/src/lib.rs index 88ba98b9..cfb8957b 100644 --- a/crates/common/types/src/lib.rs +++ b/crates/common/types/src/lib.rs @@ -1,6 +1,7 @@ pub mod aggregator; pub mod attestation; pub mod block; +pub mod chain_config; pub mod checkpoint; pub mod constants; pub mod genesis; diff --git a/crates/common/types/src/state.rs b/crates/common/types/src/state.rs index 6cc25bb4..d117151e 100644 --- a/crates/common/types/src/state.rs +++ b/crates/common/types/src/state.rs @@ -15,7 +15,7 @@ use primitives::HashTreeRoot as _; #[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] pub struct State { /// The chain's configuration parameters - pub config: ChainConfig, + pub config: StateConfig, /// The current slot number pub slot: u64, /// The header of the most recent block @@ -38,8 +38,9 @@ pub struct State { /// The maximum number of historical block roots to store in the state. /// -/// With a 4-second slot, this corresponds to a history -/// of approximately 12.1 days. +/// With the default 4-second slot, this corresponds to a history of +/// approximately 12.1 days; a network running a longer slot covers +/// proportionally more time with the same limit. pub const HISTORICAL_ROOTS_LIMIT: usize = 262_144; // 2**18 /// List of historical block root hashes up to historical_roots_limit. @@ -100,7 +101,7 @@ impl State { let justifications_validators = JustificationValidators::new(); Self { - config: ChainConfig { genesis_time }, + config: StateConfig { genesis_time }, slot: 0, latest_block_header: genesis_header, latest_justified: Checkpoint::default(), @@ -114,8 +115,13 @@ impl State { } } +/// The chain config carried inside [`State`], the spec's `Config` container. +/// +/// Merkleized into the state root, so its layout is fixed by the spec and no +/// field may be added here. [`crate::chain_config::ChainConfig`] is the node's +/// own view: this plus the slot duration. #[derive(Debug, Clone, Serialize, Deserialize, SszEncode, SszDecode, HashTreeRoot)] -pub struct ChainConfig { +pub struct StateConfig { pub genesis_time: u64, } diff --git a/crates/common/types/tests/ssz_spectests.rs b/crates/common/types/tests/ssz_spectests.rs index ec318b90..65e21676 100644 --- a/crates/common/types/tests/ssz_spectests.rs +++ b/crates/common/types/tests/ssz_spectests.rs @@ -28,7 +28,7 @@ fn run(path: &Path) -> datatest_stable::Result<()> { fn run_ssz_test(test: &SszTestCase) -> datatest_stable::Result<()> { match test.type_name.as_str() { // Consensus containers - "Config" => run_typed_test::(test), + "Config" => run_typed_test::(test), "Checkpoint" => { run_typed_test::(test) } diff --git a/crates/net/p2p/src/lib.rs b/crates/net/p2p/src/lib.rs index dd74bc1e..685032f9 100644 --- a/crates/net/p2p/src/lib.rs +++ b/crates/net/p2p/src/lib.rs @@ -179,8 +179,15 @@ pub struct SwarmConfig { /// Attestation subnets to subscribe to, precomputed via /// [`attestation_subscription_subnets`]. pub subscription_subnets: HashSet, + /// Slot duration from the network's config file. Gossipsub's duplicate + /// cache is specified in slots, so it has to follow the network's cadence. + pub milliseconds_per_slot: u64, } +/// Width of gossipsub's duplicate cache, in slots: leanSpec sets +/// `seen_ttl = SECONDS_PER_SLOT * JUSTIFICATION_LOOKBACK_SLOTS * 2`. +const DUPLICATE_CACHE_SLOTS: u64 = 3 * 2; + /// The attestation subnets a node subscribes to: every validator subscribes /// to its own committee subnet (`validator_id % attestation_committee_count`) /// for mesh health, and an aggregator additionally subscribes to any explicit @@ -239,8 +246,9 @@ pub fn build_swarm( .fanout_ttl(Duration::from_secs(60)) .history_length(6) .history_gossip(3) - // seen_ttl_secs = seconds_per_slot * justification_lookback_slots * 2 - .duplicate_cache_time(Duration::from_secs(4 * 3 * 2)) + .duplicate_cache_time(Duration::from_millis( + config.milliseconds_per_slot * DUPLICATE_CACHE_SLOTS, + )) .validation_mode(ValidationMode::Anonymous) .message_id_fn(compute_message_id) // Taken from ream diff --git a/crates/net/p2p/src/req_resp/handlers.rs b/crates/net/p2p/src/req_resp/handlers.rs index ab7421f5..897ade8f 100644 --- a/crates/net/p2p/src/req_resp/handlers.rs +++ b/crates/net/p2p/src/req_resp/handlers.rs @@ -589,6 +589,7 @@ async fn handle_fetch_failure( mod tests { use super::*; use ethlambda_storage::{ForkCheckpoints, backend::InMemoryBackend}; + use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT; use ethlambda_types::{ block::{Block, BlockBody, MultiMessageAggregate}, state::State, @@ -611,7 +612,11 @@ mod tests { #[test] fn blocks_by_range_returns_canonical_blocks_in_requested_order() { let backend = Arc::new(InMemoryBackend::new()); - let mut store = Store::from_anchor_state(backend, State::from_genesis(0, vec![])); + let mut store = Store::from_anchor_state( + backend, + State::from_genesis(0, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); let block_1 = signed_block(1, store.head().expect("head block exists")); let root_1 = block_1.message.hash_tree_root(); diff --git a/crates/net/rpc/src/events.rs b/crates/net/rpc/src/events.rs index 6d4bf9a3..86eaf3cf 100644 --- a/crates/net/rpc/src/events.rs +++ b/crates/net/rpc/src/events.rs @@ -122,6 +122,7 @@ mod tests { }; use ethlambda_blockchain::{ChainEvent, EventBus}; use ethlambda_storage::{Store, backend::InMemoryBackend}; + use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT; use futures_util::StreamExt; use http_body_util::BodyExt; use std::sync::Arc; @@ -130,7 +131,11 @@ mod tests { use crate::test_utils::create_test_state; async fn events_response(events: &EventBus, uri: &str) -> axum::response::Response { - let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); + let store = Store::from_anchor_state( + Arc::new(InMemoryBackend::new()), + create_test_state(), + DEFAULT_MILLISECONDS_PER_SLOT, + ); let app = crate::test_utils::test_api_router(store).layer(Extension(events.clone())); app.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) .await diff --git a/crates/net/rpc/src/fork_choice.rs b/crates/net/rpc/src/fork_choice.rs index 1dd7dfba..65ea6c93 100644 --- a/crates/net/rpc/src/fork_choice.rs +++ b/crates/net/rpc/src/fork_choice.rs @@ -99,6 +99,7 @@ mod tests { use super::*; use axum::{Router, body::Body, http::Request, http::StatusCode}; use ethlambda_storage::{Store, backend::InMemoryBackend}; + use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT; use http_body_util::BodyExt; use std::sync::Arc; use tower::ServiceExt; @@ -113,7 +114,7 @@ mod tests { async fn test_get_fork_choice_returns_json() { let state = create_test_state(); let backend = Arc::new(InMemoryBackend::new()); - let store = Store::from_anchor_state(backend, state); + let store = Store::from_anchor_state(backend, state, DEFAULT_MILLISECONDS_PER_SLOT); let app = build_test_router(store); @@ -150,7 +151,7 @@ mod tests { async fn test_get_fork_choice_ui_returns_html() { let state = create_test_state(); let backend = Arc::new(InMemoryBackend::new()); - let store = Store::from_anchor_state(backend, state); + let store = Store::from_anchor_state(backend, state, DEFAULT_MILLISECONDS_PER_SLOT); let app = build_test_router(store); diff --git a/crates/net/rpc/src/genesis.rs b/crates/net/rpc/src/genesis.rs index 45638360..76633aca 100644 --- a/crates/net/rpc/src/genesis.rs +++ b/crates/net/rpc/src/genesis.rs @@ -33,6 +33,7 @@ mod tests { http::{Request, StatusCode}, }; use ethlambda_storage::{Store, backend::InMemoryBackend}; + use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT; use ethlambda_types::state::{State, Validator}; use http_body_util::BodyExt; use std::sync::Arc; @@ -49,7 +50,11 @@ mod tests { let validators = vec![dummy_validator(0), dummy_validator(1), dummy_validator(2)]; let state = State::from_genesis(1000, validators); - let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), state); + let store = Store::from_anchor_state( + Arc::new(InMemoryBackend::new()), + state, + DEFAULT_MILLISECONDS_PER_SLOT, + ); let app = routes().with_state(store); let resp = app .oneshot( diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index 6674b0b7..32858471 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -146,7 +146,7 @@ pub(crate) mod test_utils { block::{Block, BlockBody, BlockHeader}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, - state::{ChainConfig, JustificationValidators, JustifiedSlots, State}, + state::{JustificationValidators, JustifiedSlots, State, StateConfig}, }; use libssz::SszEncode; @@ -173,7 +173,7 @@ pub(crate) mod test_utils { }; State { - config: ChainConfig { genesis_time: 1000 }, + config: StateConfig { genesis_time: 1000 }, slot: 0, latest_block_header: genesis_header, latest_justified: genesis_checkpoint, @@ -228,6 +228,7 @@ mod tests { use super::*; use axum::{body::Body, http::Request, http::StatusCode, http::header}; use ethlambda_storage::{ForkCheckpoints, Store, backend::InMemoryBackend}; + use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT; use http_body_util::BodyExt; use serde_json::json; use std::sync::Arc; @@ -239,7 +240,7 @@ mod tests { async fn test_get_latest_justified_checkpoint() { let state = create_test_state(); let backend = Arc::new(InMemoryBackend::new()); - let store = Store::from_anchor_state(backend, state); + let store = Store::from_anchor_state(backend, state, DEFAULT_MILLISECONDS_PER_SLOT); let app = test_utils::test_api_router(store.clone()); @@ -277,7 +278,7 @@ mod tests { use libssz::SszEncode; let state = create_test_state(); let backend = Arc::new(InMemoryBackend::new()); - let store = Store::from_anchor_state(backend, state); + let store = Store::from_anchor_state(backend, state, DEFAULT_MILLISECONDS_PER_SLOT); // Build expected SSZ with zeroed state_root (canonical post-state form) let finalized = store @@ -337,7 +338,8 @@ mod tests { vec![H256::ZERO, target_root].try_into().unwrap(); anchor_state.justified_slots = JustifiedSlots::with_length(2).unwrap(); - let store = Store::from_anchor_state(backend, anchor_state); + let store = + Store::from_anchor_state(backend, anchor_state, DEFAULT_MILLISECONDS_PER_SLOT); (store, target_root) } @@ -360,7 +362,7 @@ mod tests { let state = create_test_state(); let anchor_root = anchor_root_of(&state); let backend = Arc::new(InMemoryBackend::new()); - let store = Store::from_anchor_state(backend, state); + let store = Store::from_anchor_state(backend, state, DEFAULT_MILLISECONDS_PER_SLOT); let app = test_utils::test_api_router(store); let response = send(app, &format!("/lean/v0/blocks/0x{anchor_root:x}")).await; @@ -381,7 +383,7 @@ mod tests { let state = create_test_state(); let anchor_root = anchor_root_of(&state); let backend = Arc::new(InMemoryBackend::new()); - let store = Store::from_anchor_state(backend, state); + let store = Store::from_anchor_state(backend, state, DEFAULT_MILLISECONDS_PER_SLOT); let app = test_utils::test_api_router(store); let response = send(app, &format!("/lean/v0/blocks/0x{anchor_root:x}/header")).await; @@ -414,7 +416,7 @@ mod tests { async fn get_block_invalid_id_returns_400() { let state = create_test_state(); let backend = Arc::new(InMemoryBackend::new()); - let store = Store::from_anchor_state(backend, state); + let store = Store::from_anchor_state(backend, state, DEFAULT_MILLISECONDS_PER_SLOT); let app = test_utils::test_api_router(store); let response = send(app, "/lean/v0/blocks/not-a-valid-id").await; @@ -426,7 +428,7 @@ mod tests { async fn get_block_missing_root_returns_404() { let state = create_test_state(); let backend = Arc::new(InMemoryBackend::new()); - let store = Store::from_anchor_state(backend, state); + let store = Store::from_anchor_state(backend, state, DEFAULT_MILLISECONDS_PER_SLOT); let app = test_utils::test_api_router(store); let missing = format!("0x{}", "aa".repeat(32)); @@ -468,7 +470,7 @@ mod tests { let state = create_test_state(); let backend = Arc::new(InMemoryBackend::new()); - let mut store = Store::from_anchor_state(backend, state); + let mut store = Store::from_anchor_state(backend, state, DEFAULT_MILLISECONDS_PER_SLOT); // Build a non-genesis signed block with empty body and empty proof blob. let block = Block { @@ -537,7 +539,7 @@ mod tests { // the HTTP endpoint stays consistent and returns 200 rather than 404. let state = create_test_state(); let backend = Arc::new(InMemoryBackend::new()); - let store = Store::from_anchor_state(backend, state); + let store = Store::from_anchor_state(backend, state, DEFAULT_MILLISECONDS_PER_SLOT); // The body the endpoint serves must round-trip to a `SignedBlock` // matching the genesis header paired with the synthetic blank proof — diff --git a/crates/net/rpc/src/node.rs b/crates/net/rpc/src/node.rs index 25f67258..ab0999cc 100644 --- a/crates/net/rpc/src/node.rs +++ b/crates/net/rpc/src/node.rs @@ -1,6 +1,6 @@ use axum::{Extension, Router, extract::State, response::IntoResponse, routing::get}; +use ethlambda_blockchain::SyncStatusController; use ethlambda_blockchain::metrics::SyncStatus; -use ethlambda_blockchain::{MILLISECONDS_PER_SLOT, SyncStatusController}; use ethlambda_storage::Store; use serde::Serialize; @@ -38,12 +38,12 @@ async fn get_syncing( State(store): State, Extension(sync_status): Extension, ) -> impl IntoResponse { - let genesis_ms = store.config().genesis_time.saturating_mul(1000); + let genesis_ms = store.config().genesis_time_ms(); let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(genesis_ms); - let wall_slot = now_ms.saturating_sub(genesis_ms) / MILLISECONDS_PER_SLOT; + let wall_slot = now_ms.saturating_sub(genesis_ms) / store.config().milliseconds_per_slot; let head_slot = store.head_slot(); let sync_distance = wall_slot.saturating_sub(head_slot); let finalized_slot = store @@ -85,7 +85,8 @@ mod tests { use ethlambda_blockchain::SyncStatusController; use ethlambda_blockchain::metrics::SyncStatus; use ethlambda_storage::{Store, backend::InMemoryBackend}; - use ethlambda_types::state::ChainConfig; + use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT; + use ethlambda_types::state::StateConfig; use http_body_util::BodyExt; use std::sync::Arc; use tower::ServiceExt; @@ -117,7 +118,11 @@ mod tests { // Assert it's clearly far behind, not a small transient lag. (is_syncing // comes from the controller, not sync_distance; see // node_syncing_reflects_controller.) - let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); + let store = Store::from_anchor_state( + Arc::new(InMemoryBackend::new()), + create_test_state(), + DEFAULT_MILLISECONDS_PER_SLOT, + ); let json = get_syncing_json(store, SyncStatusController::default()).await; assert_eq!(json["head_slot"], 0); assert_eq!(json["finalized_slot"], 0); @@ -133,10 +138,14 @@ mod tests { // Set genesis_time to far future so wall_slot=0 and head_slot=0 → sync_distance=0. let mut state = create_test_state(); // Unix timestamp ~year 2100 (4102444800 seconds), well beyond any test run. - state.config = ChainConfig { + state.config = StateConfig { genesis_time: 4_102_444_800, }; - let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), state); + let store = Store::from_anchor_state( + Arc::new(InMemoryBackend::new()), + state, + DEFAULT_MILLISECONDS_PER_SLOT, + ); let json = get_syncing_json(store, SyncStatusController::default()).await; assert_eq!(json["head_slot"], 0); assert_eq!(json["finalized_slot"], 0); @@ -148,7 +157,11 @@ mod tests { // is_syncing comes from the shared SyncStatusController (the actor's sync // decision), not the raw wall-clock sync_distance. It follows the // controller and updates through the shared handle without rebuilding it. - let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); + let store = Store::from_anchor_state( + Arc::new(InMemoryBackend::new()), + create_test_state(), + DEFAULT_MILLISECONDS_PER_SLOT, + ); let sync = SyncStatusController::new(SyncStatus::Syncing); assert_eq!( @@ -167,7 +180,11 @@ mod tests { const VERSION: &str = "ethlambda/v9.9.9-test-deadbeef/x86_64-unknown-linux-gnu/rustc-v1.92.0"; const PEER_ID: &str = "16Uiu2HAmTestPeerIdSentinel"; - let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); + let store = Store::from_anchor_state( + Arc::new(InMemoryBackend::new()), + create_test_state(), + DEFAULT_MILLISECONDS_PER_SLOT, + ); let app = crate::build_api_router(store, VERSION, PEER_ID.to_string()); let resp = app .oneshot( diff --git a/crates/net/rpc/src/spec.rs b/crates/net/rpc/src/spec.rs index 01c593aa..ca3136d0 100644 --- a/crates/net/rpc/src/spec.rs +++ b/crates/net/rpc/src/spec.rs @@ -1,5 +1,5 @@ -use axum::{Router, response::IntoResponse, routing::get}; -use ethlambda_blockchain::{INTERVALS_PER_SLOT, MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT}; +use axum::{Router, extract::State, response::IntoResponse, routing::get}; +use ethlambda_blockchain::INTERVALS_PER_SLOT; use ethlambda_storage::Store; use ethlambda_types::{constants::FORK_DIGEST, state::HISTORICAL_ROOTS_LIMIT}; use serde::Serialize; @@ -20,11 +20,17 @@ struct SpecResponse { fork_digest: &'static str, } -async fn get_spec() -> impl IntoResponse { +/// Serve the timing parameters this node is actually running on. +/// +/// The slot duration comes from the network's config file rather than a +/// compile-time constant, so it is read from the store instead of being baked +/// into the response. +async fn get_spec(State(store): State) -> impl IntoResponse { + let config = store.config(); json_response(SpecResponse { - ms_per_slot: MILLISECONDS_PER_SLOT, + ms_per_slot: config.milliseconds_per_slot, intervals_per_slot: INTERVALS_PER_SLOT, - ms_per_interval: MILLISECONDS_PER_INTERVAL, + ms_per_interval: config.milliseconds_per_interval(), historical_roots_limit: HISTORICAL_ROOTS_LIMIT as u64, fork_digest: FORK_DIGEST, }) @@ -42,18 +48,20 @@ mod tests { body::Body, http::{Request, StatusCode}, }; - use ethlambda_blockchain::{ - INTERVALS_PER_SLOT, MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, - }; + use ethlambda_blockchain::INTERVALS_PER_SLOT; use ethlambda_storage::{Store, backend::InMemoryBackend}; + use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT; use ethlambda_types::state::HISTORICAL_ROOTS_LIMIT; use http_body_util::BodyExt; use std::sync::Arc; use tower::ServiceExt; - #[tokio::test] - async fn spec_returns_lean_constants() { - let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); + async fn spec_body(milliseconds_per_slot: u64) -> serde_json::Value { + let store = Store::from_anchor_state( + Arc::new(InMemoryBackend::new()), + create_test_state(), + milliseconds_per_slot, + ); let app = crate::test_utils::test_api_router(store); let resp = app .oneshot( @@ -66,14 +74,34 @@ mod tests { .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = resp.into_body().collect().await.unwrap().to_bytes(); - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(json["MILLISECONDS_PER_SLOT"], MILLISECONDS_PER_SLOT); + serde_json::from_slice(&body).unwrap() + } + + #[tokio::test] + async fn spec_returns_lean_constants() { + let json = spec_body(DEFAULT_MILLISECONDS_PER_SLOT).await; + + assert_eq!(json["MILLISECONDS_PER_SLOT"], DEFAULT_MILLISECONDS_PER_SLOT); assert_eq!(json["INTERVALS_PER_SLOT"], INTERVALS_PER_SLOT); - assert_eq!(json["MILLISECONDS_PER_INTERVAL"], MILLISECONDS_PER_INTERVAL); + assert_eq!( + json["MILLISECONDS_PER_INTERVAL"], + DEFAULT_MILLISECONDS_PER_SLOT / INTERVALS_PER_SLOT + ); assert_eq!( json["HISTORICAL_ROOTS_LIMIT"], HISTORICAL_ROOTS_LIMIT as u64 ); assert_eq!(json["FORK_DIGEST"], FORK_DIGEST); } + + /// The endpoint is what other clients and tooling read the cadence from, so + /// it has to follow the config file rather than a compile-time constant. + #[tokio::test] + async fn spec_reports_the_configured_slot_duration() { + let json = spec_body(8_000).await; + + assert_eq!(json["MILLISECONDS_PER_SLOT"], 8_000); + assert_eq!(json["MILLISECONDS_PER_INTERVAL"], 1_600); + assert_eq!(json["INTERVALS_PER_SLOT"], INTERVALS_PER_SLOT); + } } diff --git a/crates/net/rpc/src/test_driver.rs b/crates/net/rpc/src/test_driver.rs index fc9f98d5..ffb76d59 100644 --- a/crates/net/rpc/src/test_driver.rs +++ b/crates/net/rpc/src/test_driver.rs @@ -37,6 +37,7 @@ use ethlambda_test_fixtures::{ Block as FixtureBlock, TestState, fork_choice::ForkChoiceStep, state_transition::StateTransitionRunRequest, verify_signatures::TestSignedBlock, }; +use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT; use ethlambda_types::{ block::Block, checkpoint::Checkpoint, @@ -79,7 +80,11 @@ pub type DriverState = Arc>; /// Used as the placeholder seed before the first `fork_choice/init` call. pub fn empty_driver_store() -> Store { let backend = Arc::new(InMemoryBackend::new()); - Store::from_anchor_state(backend, State::from_genesis(0, vec![])) + Store::from_anchor_state( + backend, + State::from_genesis(0, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ) } /// Build the test-driver router, including a `/lean/v0/health` endpoint so the @@ -133,7 +138,7 @@ struct VerifySignaturesRequest { struct DriverSnapshot { head_slot: u64, head_root: H256, - /// Store time in 800 ms intervals since genesis (matches [`Store::time`]). + /// Store time in intervals since genesis (matches [`Store::time`]). time: u64, /// `Checkpoint` already serializes as `{root, slot}`, which is the shape /// hive's `DriverCheckpoint` expects; no wrapper type needed. @@ -205,7 +210,7 @@ async fn init_fork_choice( } let backend = Arc::new(InMemoryBackend::new()); - let new_store = Store::from_anchor_state(backend, state); + let new_store = Store::from_anchor_state(backend, state, DEFAULT_MILLISECONDS_PER_SLOT); *driver.write().await = new_store; diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 05621f8a..00dd3e80 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -16,11 +16,12 @@ use ethlambda_types::{ block::{ Block, BlockBody, BlockHeader, MultiMessageAggregate, SignedBlock, SingleMessageAggregate, }, + chain_config::ChainConfig, checkpoint::Checkpoint, constants::INTERVALS_PER_SLOT, genesis::GenesisConfig, primitives::{H256, HashTreeRoot as _}, - state::{ChainConfig, State, anchor_pair_is_consistent}, + state::{State, anchor_pair_is_consistent}, }; use libssz::{SszDecode, SszEncode}; @@ -99,7 +100,9 @@ const KEY_LATEST_FINALIZED: &[u8] = b"latest_finalized"; /// /// Snapshots are the only entries written to `States` (plus the bootstrap /// anchor); they are never pruned and bound state-reconstruction diff walks to -/// at most this many steps. ~68 minutes at 4-second slots. +/// at most this many steps. A slot count, not a duration: the walk cost is +/// per-slot, so it does not follow the configured cadence. ~68 minutes at the +/// default 4-second slots. const SNAPSHOT_ANCHOR_INTERVAL: u64 = 1_024; /// Number of reconstructed/imported states memoized in memory. @@ -113,22 +116,26 @@ const STATE_CACHE_CAPACITY: usize = 32; /// Keep block proofs for at least this many slots below the tip, even once /// finalized. Proofs older than this window are pruned only when the window /// lies entirely within finalized history; see [`Store::prune_old_block_proofs`]. -/// ~1 day at 4-second slots. +/// ~1 day at the default 4-second slots, proportionally longer on a slower one. const BLOCK_PROOF_PRUNING_RANGE: u64 = 21_600; -/// ~30 minutes of resume window at 4-second slots (1800 / 4 = 450). +/// Resume window, in slots. A slot count rather than a wall-clock window: the +/// cost of resuming is replaying this many slots, whatever they last. ~30 +/// minutes at the default 4-second slots (1800 / 4 = 450). pub const MAX_RESUMABLE_DB_STATE_AGE: u64 = 450; /// Hard cap for the known aggregated payload buffer (number of distinct attestation messages). -/// With 1 attestation/slot, this holds ~500 messages (~33 min at 4s/slot). +/// With 1 attestation/slot, this holds ~500 messages (~33 min at the default +/// 4s/slot). const AGGREGATED_PAYLOAD_CAP: usize = 512; /// Hard cap for the new (pending) aggregated payload buffer. -/// Smaller than known since new payloads are drained every interval (~4s). +/// Smaller than known since new payloads are drained every interval. const NEW_PAYLOAD_CAP: usize = 64; /// Hard cap for the gossip signature buffer (individual signatures, not distinct data_roots). -/// With 4 validators and 4-second slots, 2048 signatures covers ~512 slots (~34 min). +/// With 4 validators, 2048 signatures covers ~512 slots (~34 min at the +/// default 4-second slots). /// Each XMSS signature is ~3KB, so worst-case memory is ~6 MB. const GOSSIP_SIGNATURE_CAP: usize = 2048; @@ -541,9 +548,9 @@ pub struct Store { /// The config is written once at bootstrap and has no setter, so a plain copy /// per `Store` cannot go stale: sharing it behind an `Arc` would buy nothing. /// It stays in `Table::Metadata` under `KEY_CONFIG` because `from_db_state` - /// reads it back to reject a DB whose `genesis_time` disagrees with the config - /// file; this field only spares every caller a backend round trip and a - /// `Result` it could never act on. + /// reads it back to reject a DB whose genesis time or slot duration disagrees + /// with the config file; this field only spares every caller a backend round + /// trip and a `Result` it could never act on. config: ChainConfig, new_payloads: Arc>, known_payloads: Arc>, @@ -567,8 +574,16 @@ impl Store { /// /// Uses the state's `latest_block_header` as the anchor block header. /// No block body is stored since it's not available. - pub fn from_anchor_state(backend: Arc, anchor_state: State) -> Self { - Self::init_store(backend, anchor_state, None) + /// + /// `milliseconds_per_slot` comes from the network's config file: the anchor + /// state carries the genesis time but not the cadence, which the spec's SSZ + /// `Config` has no field for. + pub fn from_anchor_state( + backend: Arc, + anchor_state: State, + milliseconds_per_slot: u64, + ) -> Self { + Self::init_store(backend, anchor_state, None, milliseconds_per_slot) .expect("store initialization should succeed in from_anchor_state") } @@ -586,6 +601,7 @@ impl Store { backend: Arc, mut anchor_state: State, anchor_block: Block, + milliseconds_per_slot: u64, ) -> Result { if !anchor_pair_is_consistent(&mut anchor_state, &anchor_block) { return Err(GetForkchoiceStoreError::AnchorPairInconsistent { @@ -594,10 +610,13 @@ impl Store { }); } - Ok( - Self::init_store(backend, anchor_state, Some(anchor_block.body)) - .expect("store initialization should succeed in get_forkchoice_store"), + Ok(Self::init_store( + backend, + anchor_state, + Some(anchor_block.body), + milliseconds_per_slot, ) + .expect("store initialization should succeed in get_forkchoice_store")) } /// Build a Store from the state already persisted in the storage backend. @@ -631,8 +650,26 @@ impl Store { { return Ok(None); } - ChainConfig::from_ssz_bytes(&bytes).expect("valid config") + ChainConfig::from_persisted_ssz_bytes(&bytes).expect("valid config") }; + + // The slot duration is absent from the state, so `verify_state` below + // cannot see it: compare the persisted config directly. A data + // directory built at another cadence indexes its blocks against a + // different time grid, which makes it as foreign as another genesis. + genesis + .verify_time_config(&persisted_config) + .inspect_err(|err| { + error!( + %err, + db_genesis_time = persisted_config.genesis_time, + db_milliseconds_per_slot = persisted_config.milliseconds_per_slot, + expected_genesis_time = genesis.genesis_time, + expected_milliseconds_per_slot = genesis.milliseconds_per_slot, + "Persisted DB was built on a different time grid; refusing to reuse this data directory" + ) + })?; + let store = Self { backend, config: persisted_config, @@ -645,9 +682,9 @@ impl Store { state_cache: new_state_cache(), }; - // Compare against the finalized state rather than the persisted - // `ChainConfig`: the config carries only `genesis_time`, so it cannot - // catch a chain that shares our genesis time but not our validator + // Also compare against the finalized state: the persisted config + // carries no validator registry, so the check above cannot catch a + // chain that shares our genesis time and cadence but not our validator // set. Finalized is chosen over head because it is the state the // anchor is rebuilt from and it never gets pruned. let finalized = store.latest_finalized()?.root; @@ -676,7 +713,9 @@ impl Store { backend: Arc, mut anchor_state: State, anchor_body: Option, + milliseconds_per_slot: u64, ) -> Result { + let config = ChainConfig::new(anchor_state.config.genesis_time, milliseconds_per_slot); // Save original state_root for validation let original_state_root = anchor_state.latest_block_header.state_root; @@ -709,7 +748,7 @@ impl Store { // Metadata let metadata_entries = vec![ (KEY_TIME.to_vec(), 0u64.to_ssz()), - (KEY_CONFIG.to_vec(), anchor_state.config.to_ssz()), + (KEY_CONFIG.to_vec(), config.to_ssz()), (KEY_HEAD.to_vec(), anchor_block_root.to_ssz()), (KEY_SAFE_TARGET.to_vec(), anchor_block_root.to_ssz()), (KEY_LATEST_JUSTIFIED.to_vec(), anchor_checkpoint.to_ssz()), @@ -770,7 +809,7 @@ impl Store { Ok(Self { backend, - config: anchor_state.config, + config, new_payloads: Arc::new(Mutex::new(PayloadBuffer::new(NEW_PAYLOAD_CAP))), known_payloads: Arc::new(Mutex::new(PayloadBuffer::new(AGGREGATED_PAYLOAD_CAP))), fork_choice: Default::default(), @@ -805,7 +844,8 @@ impl Store { /// Returns the current store time in interval counts since genesis. /// - /// Each increment represents one 800ms interval. Use [`Self::current_slot`] + /// Each increment represents one interval, a fifth of the configured slot. + /// Use [`Self::current_slot`] /// for the slot; the interval within it is `time() % INTERVALS_PER_SLOT`. pub fn time(&self) -> Result { self.get_metadata(KEY_TIME) @@ -1781,6 +1821,7 @@ fn write_signed_block( mod tests { use super::*; use crate::backend::InMemoryBackend; + use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT; use ethlambda_types::genesis::{GenesisMismatch, GenesisValidatorEntry}; /// Validator at `index` whose two pubkeys are filled with `seed`, so @@ -1798,6 +1839,7 @@ mod tests { fn genesis_config(genesis_time: u64, validators: &[Validator]) -> GenesisConfig { GenesisConfig { genesis_time, + milliseconds_per_slot: DEFAULT_MILLISECONDS_PER_SLOT, genesis_validators: validators .iter() .map(|v| GenesisValidatorEntry { @@ -1921,7 +1963,7 @@ mod tests { let backend = Arc::new(InMemoryBackend::new()); Self { backend, - config: ChainConfig { genesis_time: 0 }, + config: ChainConfig::new(0, DEFAULT_MILLISECONDS_PER_SLOT), new_payloads: Arc::new(Mutex::new(PayloadBuffer::new(NEW_PAYLOAD_CAP))), known_payloads: Arc::new(Mutex::new(PayloadBuffer::new(AGGREGATED_PAYLOAD_CAP))), fork_choice: Default::default(), @@ -1937,7 +1979,7 @@ mod tests { fn test_store_with_backend(backend: Arc) -> Self { Self { backend, - config: ChainConfig { genesis_time: 0 }, + config: ChainConfig::new(0, DEFAULT_MILLISECONDS_PER_SLOT), new_payloads: Arc::new(Mutex::new(PayloadBuffer::new(NEW_PAYLOAD_CAP))), known_payloads: Arc::new(Mutex::new(PayloadBuffer::new(AGGREGATED_PAYLOAD_CAP))), fork_choice: Default::default(), @@ -1954,7 +1996,11 @@ mod tests { #[test] fn block_root_index_tracks_canonical_chain_across_reorgs() { let backend = Arc::new(InMemoryBackend::new()); - let mut store = Store::from_anchor_state(backend, State::from_genesis(0, vec![])); + let mut store = Store::from_anchor_state( + backend, + State::from_genesis(0, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); let anchor_root = store.head().expect("head root"); let block_1 = signed_block(1, anchor_root); @@ -2014,8 +2060,11 @@ mod tests { #[test] fn from_db_state_preserves_block_root_index() { let backend = Arc::new(InMemoryBackend::new()); - let mut store = - Store::from_anchor_state(backend.clone(), State::from_genesis(12345, vec![])); + let mut store = Store::from_anchor_state( + backend.clone(), + State::from_genesis(12345, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); let block = signed_block(1, store.head().expect("head root")); let block_root = block.message.hash_tree_root(); @@ -3026,7 +3075,11 @@ mod tests { #[test] fn get_signed_block_synthesizes_blank_proof_for_genesis_anchor() { let backend: Arc = Arc::new(InMemoryBackend::new()); - let store = Store::from_anchor_state(backend, State::from_genesis(0, vec![])); + let store = Store::from_anchor_state( + backend, + State::from_genesis(0, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); let head_root = store.head().expect("head root must exist"); let signed = store @@ -3063,7 +3116,11 @@ mod tests { .expect("put header"); batch.commit().expect("commit"); - let store = Store::from_anchor_state(backend, State::from_genesis(0, vec![])); + let store = Store::from_anchor_state( + backend, + State::from_genesis(0, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); assert!( store .get_signed_block(&root) @@ -3077,7 +3134,11 @@ mod tests { #[test] fn from_anchor_state_stores_bootstrap_snapshot() { let backend: Arc = Arc::new(InMemoryBackend::new()); - let store = Store::from_anchor_state(backend.clone(), State::from_genesis(0, vec![])); + let store = Store::from_anchor_state( + backend.clone(), + State::from_genesis(0, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); let anchor_root = store.head().expect("Failed to get head block root"); assert!(has_key(backend.as_ref(), Table::States, &anchor_root)); @@ -3099,7 +3160,11 @@ mod tests { fn from_db_state_returns_some_on_matching_genesis() { let backend: Arc = Arc::new(InMemoryBackend::new()); // Write an initial state to the backend. - let _ = Store::from_anchor_state(backend.clone(), State::from_genesis(12345, vec![])); + let _ = Store::from_anchor_state( + backend.clone(), + State::from_genesis(12345, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); assert!( Store::from_db_state(backend, &genesis_config(12345, &[])) .expect("Failed to get store") @@ -3113,7 +3178,11 @@ mod tests { fn from_db_state_errors_on_genesis_time_mismatch() { let backend: Arc = Arc::new(InMemoryBackend::new()); // Write an initial state to the backend. - let _ = Store::from_anchor_state(backend.clone(), State::from_genesis(12345, vec![])); + let _ = Store::from_anchor_state( + backend.clone(), + State::from_genesis(12345, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); // `Store` is not `Debug`, so unwrap the error by pattern rather than // with `expect_err`. let Err(err) = Store::from_db_state(backend, &genesis_config(99999, &[])) else { @@ -3128,6 +3197,66 @@ mod tests { )); } + /// The case neither the state nor the validator registry can see: the slot + /// duration is deliberately absent from the SSZ state, so it has to be + /// caught against the persisted config. + #[test] + fn from_db_state_errors_on_slot_duration_mismatch() { + let backend: Arc = Arc::new(InMemoryBackend::new()); + let _ = Store::from_anchor_state( + backend.clone(), + State::from_genesis(12345, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + + let mut genesis = genesis_config(12345, &[]); + genesis.milliseconds_per_slot = 8_000; + let Err(err) = Store::from_db_state(backend, &genesis) else { + panic!("slot duration mismatch must be fatal"); + }; + assert!(matches!( + err, + Error::GenesisMismatch(GenesisMismatch::SlotDuration { + expected: 8_000, + got: DEFAULT_MILLISECONDS_PER_SLOT, + }) + )); + } + + /// A data directory written before the slot duration was persisted holds a + /// bare SSZ `StateConfig` under `KEY_CONFIG`. It ran the default cadence, + /// so it must still resume rather than fail to decode. + #[test] + fn from_db_state_resumes_a_pre_slot_duration_data_directory() { + use ethlambda_types::state::StateConfig; + + let backend: Arc = Arc::new(InMemoryBackend::new()); + let _ = Store::from_anchor_state( + backend.clone(), + State::from_genesis(12345, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + + // Roll `KEY_CONFIG` back to the legacy layout. + let legacy = StateConfig { + genesis_time: 12345, + }; + let mut batch = backend.begin_write().expect("write batch"); + let entries = vec![(KEY_CONFIG.to_vec(), legacy.to_ssz())]; + batch + .put_batch(Table::Metadata, entries) + .expect("put legacy config"); + batch.commit().expect("commit"); + + let store = Store::from_db_state(backend, &genesis_config(12345, &[])) + .expect("legacy config must decode") + .expect("store must be resumable"); + assert_eq!( + *store.config(), + ChainConfig::new(12345, DEFAULT_MILLISECONDS_PER_SLOT) + ); + } + /// The case a `genesis_time`-only check cannot see: same network start /// time, different validator registry. #[test] @@ -3137,6 +3266,7 @@ mod tests { let _ = Store::from_anchor_state( backend.clone(), State::from_genesis(12345, persisted.clone()), + DEFAULT_MILLISECONDS_PER_SLOT, ); let mut foreign = persisted; @@ -3154,9 +3284,7 @@ mod tests { fn from_db_state_returns_none_when_latest_finalized_is_missing() { let backend: Arc = Arc::new(InMemoryBackend::new()); // Write only KEY_CONFIG, leaving KEY_LATEST_FINALIZED absent. - let config = ChainConfig { - genesis_time: 12345, - }; + let config = ChainConfig::new(12345, DEFAULT_MILLISECONDS_PER_SLOT); let mut batch = backend.begin_write().expect("write batch"); batch .put_batch( diff --git a/docs/checkpoint_sync.md b/docs/checkpoint_sync.md index 52b6296b..c6e3a510 100644 --- a/docs/checkpoint_sync.md +++ b/docs/checkpoint_sync.md @@ -79,7 +79,7 @@ To deliberately discard existing state and start over from genesis or from a che ### Foreign State -Persisted state is accepted only after it is verified against the local genesis config: same `GENESIS_TIME` and the same validator registry (count, sequential indices, and both pubkeys per validator). The validator set is fixed at genesis, so any state of this chain must carry exactly that registry. These are the same identity checks checkpoint sync applies to a downloaded state, sharing one implementation. +Persisted state is accepted only after it is verified against the local genesis config: same `GENESIS_TIME`, same `MILLISECONDS_PER_SLOT`, and the same validator registry (count, sequential indices, and both pubkeys per validator). The validator set is fixed at genesis, so any state of this chain must carry exactly that registry. These are the same identity checks checkpoint sync applies to a downloaded state, sharing one implementation. If the data directory belongs to a different network, startup **aborts** with `persisted state does not match the configured genesis: …`. It is not treated as an empty directory, because initializing a new anchor on top would leave the foreign chain's rows in place, and the slot-indexed reads behind `BlocksByRange` would then serve those blocks to peers. Point `--data-dir` at the right directory, or remove it. diff --git a/docs/data_storage.md b/docs/data_storage.md index 8b9a7568..506fdb5c 100644 --- a/docs/data_storage.md +++ b/docs/data_storage.md @@ -213,14 +213,14 @@ diff contains and how states are rebuilt. String keys mapping to SSZ-encoded scalars — the `Store`'s own persistent fields: -| Key | Type | Meaning | -| ------------------ | ------------- | ------------------------------------------------------ | -| `time` | `u64` | Intervals elapsed since genesis (the store clock) | -| `config` | `ChainConfig` | Chain configuration (currently just `genesis_time`) | -| `head` | `H256` | Current fork choice head | -| `safe_target` | `H256` | Current safe target (see [lmd_ghost.md](lmd_ghost.md)) | -| `latest_justified` | `Checkpoint` | Latest justified checkpoint | -| `latest_finalized` | `Checkpoint` | Latest finalized checkpoint | +| Key | Type | Meaning | +| ------------------ | ----------------- | ------------------------------------------------------ | +| `time` | `u64` | Intervals elapsed since genesis (the store clock) | +| `config` | `ChainConfig` | Genesis time and slot duration | +| `head` | `H256` | Current fork choice head | +| `safe_target` | `H256` | Current safe target (see [lmd_ghost.md](lmd_ghost.md)) | +| `latest_justified` | `Checkpoint` | Latest justified checkpoint | +| `latest_finalized` | `Checkpoint` | Latest finalized checkpoint | `config` is the odd one out: `init_store` writes it once at bootstrap and nothing ever rewrites it afterward (it has a getter, `Store::config`, but no @@ -230,6 +230,12 @@ reads of it never reach the backend. It is also part of the DB's fingerprint: network (see [Startup and Restore](#startup-and-restore)). Every other `Metadata` key is mutated in place as the chain progresses. +Note that this is *not* the SSZ `StateConfig` carried inside `State`. That one is +merkleized into the state root, so its layout is fixed by the spec and holds only +`genesis_time`; `ChainConfig` adds the slot duration, which the node needs to +schedule duties but which never enters a state root. A blob written before the slot +duration existed still decodes, filling in the 4-second default that chain ran on. + ### LiveChain `slot ‖ root → parent_root`. A pure **index** for fork choice: it lets @@ -432,11 +438,14 @@ its `BlockRoots` entry, the body if non-empty, a full snapshot into `States` `from_db_state` is the restore path: it reads `config` and `latest_finalized` from `Metadata`, returning `None` for an empty DB. A populated DB from another -network is fatal instead: the finalized state's genesis time and validator -registry are compared against the genesis config, and a mismatch fails with +network is fatal instead: the persisted `config`'s genesis time and slot +duration, plus the finalized state's genesis time and validator registry, are +compared against the genesis config, and a mismatch fails with `Error::GenesisMismatch` rather than being treated as empty, since writing a fresh anchor would leave the foreign chain's rows in place to be served to -peers. At startup the node prefers this path but only +peers. The slot duration has to be checked against the persisted `config` +because it is absent from the state by design. At startup the node prefers +this path but only accepts the on-disk store if its head is at most `MAX_RESUMABLE_DB_STATE_AGE = 450` slots (~30 minutes) behind the current slot; a staler DB falls through to checkpoint sync, which writes a fresh anchor on top of the existing data. diff --git a/docs/lmd_ghost.md b/docs/lmd_ghost.md index 38cca6cd..9319f04d 100644 --- a/docs/lmd_ghost.md +++ b/docs/lmd_ghost.md @@ -618,9 +618,11 @@ source code locations, and performance. ### Tick-Based Scheduling -ethlambda divides time into **4-second slots**, each split into **5 intervals** (800 ms -each), as described in [Slots and Intervals](./slots_and_intervals.md). Fork choice -operations are scheduled at specific intervals: +ethlambda divides time into slots, each split into **5 intervals**. The slot lasts +4 seconds unless the network's config file sets `MILLISECONDS_PER_SLOT`, so the 800 ms +intervals below are the default grid, as described in +[Slots and Intervals](./slots_and_intervals.md). Fork choice operations are scheduled +at specific intervals: ```text ONE SLOT (4000 ms) @@ -692,7 +694,7 @@ Ethereum Beacon Chain: | **Equivocation handling** | Not in fork choice | Equivocating validators' weight excluded | | **Attestation frequency** | Every slot | Once per epoch | | **Committee structure** | All validators attest each slot | Validators split into per-slot committees | -| **Slot duration** | 4 seconds | 12 seconds | +| **Slot duration** | 4 seconds (configurable) | 12 seconds | **No proposer boost.** The Beacon Chain adds a "proposer boost", a temporary weight bonus given to newly proposed blocks to prevent balancing attacks. ethlambda does not implement diff --git a/docs/metrics.md b/docs/metrics.md index 2b3fdfe9..061d7da0 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -142,6 +142,8 @@ here: a discovery dial that succeeds or fails shows up in These histograms record the absolute distance between a gossip message's arrival and the start of the interval it was due in, so an arrival that is early by some amount and one that is late by the same amount land in the same bucket; the counters' `position` label is what tells them apart. `inside` means the message arrived within the interval it was due in, not merely somewhere in the right slot: an attestation for slot 10 that lands during slot 10's interval 2 is `after`, not `inside`, since it missed the AttestationProduction interval it was actually due in. +The bucket boundaries are the interval and slot edges of the default 4-second cadence. Prometheus fixes buckets when a histogram is registered, so a network that sets `MILLISECONDS_PER_SLOT` reads these histograms against the default grid rather than its own; the `position` label still follows the configured interval width. + Blocks anchor to interval 0 of their own slot and attestations to interval 1 of their data slot; both are unbounded above, so a message that never arrives close to real time can be arbitrarily late. Aggregates anchor instead to the most recent aggregation-interval boundary rather than their own data slot, since a stale-group catch-up aggregate can carry a `data.slot` several slots in the past; anchoring to the latest boundary bounds the delay to one slot and rules out `before` entirely. Only gossip-received blocks are sampled here: blocks fetched via req/resp during sync are excluded, since sync backfill delivers blocks long after they were due and would swamp these histograms with catch-up noise rather than gossip-health signal. diff --git a/docs/rpc.md b/docs/rpc.md index fb2802a9..2d733f77 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -48,7 +48,10 @@ The handler emits a fixed, compact body (no whitespace): ### `GET /lean/v0/config/spec` -Protocol constants the node was built with. Keys mirror the leanSpec constant names: +Protocol parameters the node is running on. Keys mirror the leanSpec constant names. +`MILLISECONDS_PER_SLOT` and `MILLISECONDS_PER_INTERVAL` reflect the network's config +file rather than a compile-time constant, so a node on an 8-second network reports +`8000` and `1600` here: ```json { diff --git a/docs/slots_and_intervals.md b/docs/slots_and_intervals.md index 93b2bcee..aba0f844 100644 --- a/docs/slots_and_intervals.md +++ b/docs/slots_and_intervals.md @@ -1,7 +1,9 @@ # Slots and Intervals -A Lean Chain slot has a duration of 4 seconds and is divided in 5 intervals of 800 ms. -Every duty a validator owes the chain is due in one of them: +A Lean Chain slot is divided in 5 intervals of equal length. Every duty a validator +owes the chain is due in one of them. The slot lasts 4 seconds by default, so the +offsets below are the 800 ms grid; a network that sets `MILLISECONDS_PER_SLOT` in its +config file scales every offset by the same factor: | Interval | Offset | Duty | Who acts | What it publishes | | --- | --- | --- | --- | --- | @@ -30,9 +32,12 @@ what the previous one produced. A duty that overruns its interval is not resched lands late, and the slot moves on without it. > **In ethlambda:** the intervals are the `SlotInterval` variants in -> `crates/blockchain/src/lib.rs`, and their length comes from -> `MILLISECONDS_PER_INTERVAL` and `INTERVALS_PER_SLOT` in -> `crates/common/types/src/constants.rs`. +> `crates/blockchain/src/lib.rs`. `INTERVALS_PER_SLOT` is fixed in +> `crates/common/types/src/constants.rs` (each interval carries a distinct duty, so +> the count is not a knob), while the slot duration is read from the network's config +> file into `ChainConfig`, which derives the interval length from it. Every node +> on a network must agree on the value, and other clients still hold it at compile +> time: setting it only has an effect where every node reads the key. ## Interval 0: Block proposal From 14865bb043cc6a4c4779cb9320176b5263a11eeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:32:36 -0300 Subject: [PATCH 2/4] feat(config): floor the configurable slot at the spec cadence The slot-duration knob exists to slow a network down, not to speed one up. A config file asking for a shorter slot than the spec's is now rejected when it is parsed, instead of silently reshaping the timings the client holds in milliseconds rather than as a fraction of the slot. That floor lets the early-aggregation window go back to being a plain constant. Scaling it with the interval only ever guarded the two subtractions it feeds from underflowing at cadences a config file can no longer ask for, and what the window buys is wall time for a leanVM proof, which costs the same however long the slot is. Its invariant returns to a const assert, now measured against the narrowest interval any network may configure rather than a compile-time one. --- CLAUDE.md | 10 ++++--- crates/blockchain/src/aggregation.rs | 41 ++++++++++++++++------------ crates/blockchain/src/lib.rs | 30 ++++++-------------- crates/common/types/src/constants.rs | 14 ++++++++++ crates/common/types/src/genesis.rs | 39 +++++++++++++++++++++++--- docs/slots_and_intervals.md | 4 ++- 6 files changed, 89 insertions(+), 49 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5d2c6d08..05cf5048 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -312,10 +312,12 @@ GENESIS_VALIDATORS: proposal_pubkey: "b7b0f72e24801b02bda64073cb4de6699a416b37..." ``` - Validator indices are assigned sequentially (0, 1, 2, ...) based on array order -- `MILLISECONDS_PER_SLOT` must be a positive multiple of `INTERVALS_PER_SLOT`; it is - persisted in the DB's `Metadata["config"]` and a resume with a different value is - refused. Other clients ignore the key and stay at their compile-time 4s, so it only - takes effect on an all-ethlambda network +- `MILLISECONDS_PER_SLOT` must be a multiple of `INTERVALS_PER_SLOT` and at least + `MIN_MILLISECONDS_PER_SLOT`: the knob slows a network down, it does not speed one up, + since timings fixed in milliseconds (`EARLY_AGGREGATION_WINDOW`) are sized for the spec + cadence. It is persisted in the DB's `Metadata["config"]` and a resume with a different + value is refused. Other clients ignore the key and stay at their compile-time 4s, so it + only takes effect on an all-ethlambda network - All genesis state fields (checkpoints, justified_slots, etc.) initialize to zero/empty defaults - Matches Ream/Zeam format — no extra state fields in the config file diff --git a/crates/blockchain/src/aggregation.rs b/crates/blockchain/src/aggregation.rs index 69e0bcad..e5efc37f 100644 --- a/crates/blockchain/src/aggregation.rs +++ b/crates/blockchain/src/aggregation.rs @@ -2,7 +2,7 @@ //! pure functions it runs. //! //! The blockchain actor fires one aggregation session per slot — at interval 2, -//! or up to [`early_aggregation_window`] early when the 2/3 signature +//! or up to [`EARLY_AGGREGATION_WINDOW`] early when the 2/3 signature //! threshold is met — via //! [`run_aggregation_worker`]. The actor stays on its message loop; the worker //! runs the expensive XMSS proofs on a `spawn_blocking` thread and streams @@ -27,6 +27,7 @@ use ethlambda_types::{ ShortRoot, attestation::{AggregationBits, AttestationData, HashedAttestationData}, block::{ByteList512KiB, SingleMessageAggregate}, + constants::{INTERVALS_PER_SLOT, MIN_MILLISECONDS_PER_SLOT}, primitives::H256, state::Validator, }; @@ -54,23 +55,27 @@ pub(crate) fn aggregation_deadline(milliseconds_per_interval: u64) -> Duration { /// (mismatched timers, stuck proofs); we warn before blocking. pub(crate) const PRIOR_WORKER_JOIN_TIMEOUT: Duration = Duration::from_secs(2); -/// Nominal width of the early-aggregation window: a session may start at most -/// this long before the interval-2 boundary, provided the signature threshold -/// is met (see the check in `maybe_start_early_aggregation`). -const NOMINAL_EARLY_AGGREGATION_WINDOW_MS: u64 = 600; - -/// Width of the early-aggregation window, capped to one interval. +/// Width of the early-aggregation window: a session may start at most this +/// long before the interval-2 boundary, provided the signature threshold is +/// met (see the check in `maybe_start_early_aggregation`). /// -/// The window must fit within one interval: `maybe_start_early_aggregation` -/// subtracts it from the interval-2 offset, and the interval-1 tick schedules -/// the check at `milliseconds_per_interval - window`. The slot duration is -/// configurable, so a short enough cadence can make the nominal window wider -/// than an interval; clamping here keeps both subtractions in range instead of -/// underflowing them, and keeps the invariant enforced at the one place the -/// window is produced. -pub(crate) fn early_aggregation_window(milliseconds_per_interval: u64) -> Duration { - Duration::from_millis(NOMINAL_EARLY_AGGREGATION_WINDOW_MS.min(milliseconds_per_interval)) -} +/// Fixed rather than scaled with the slot duration. What the window buys is +/// wall time for the leanVM proof to land before the block that carries it, +/// and a proof costs the same however long the network's slot is. +pub(crate) const EARLY_AGGREGATION_WINDOW: Duration = Duration::from_millis(600); + +// The window must fit within one interval: `maybe_start_early_aggregation` +// subtracts it from the interval-2 offset, and the interval-1 tick schedules +// the check at `milliseconds_per_interval - EARLY_AGGREGATION_WINDOW`. The +// slot duration is configurable, so the binding case is the narrowest interval +// a config file can ask for. Keep this invariant self-enforcing so a future +// bump to the window, or a lowered floor, can't silently underflow either +// subtraction. +const _: () = assert!( + EARLY_AGGREGATION_WINDOW.as_millis() + <= (MIN_MILLISECONDS_PER_SLOT / INTERVALS_PER_SLOT) as u128, + "EARLY_AGGREGATION_WINDOW must not exceed the shortest configurable interval" +); /// A single pre-prepared aggregation group. /// @@ -171,7 +176,7 @@ impl Message for AggregationDeadline { } /// One-shot self-message scheduled at the interval-1 tick; fires when the -/// early-aggregation window opens (T2 - `early_aggregation_window`) to run +/// early-aggregation window opens (T2 - [`EARLY_AGGREGATION_WINDOW`]) to run /// the threshold check for signatures that all arrived before the window. /// Arrivals inside the window are checked per insert instead. pub(crate) struct EarlyAggregationCheck; diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index ac361681..78f17325 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -16,8 +16,8 @@ use ethlambda_types::{ use crate::aggregation::{ AggregateProduced, AggregationDeadline, AggregationDone, AggregationSession, - EarlyAggregationCheck, MAX_AGGREGATION_JOBS, PRIOR_WORKER_JOIN_TIMEOUT, aggregation_deadline, - early_aggregation_window, run_aggregation_worker, + EARLY_AGGREGATION_WINDOW, EarlyAggregationCheck, MAX_AGGREGATION_JOBS, + PRIOR_WORKER_JOIN_TIMEOUT, aggregation_deadline, run_aggregation_worker, }; use crate::key_manager::ValidatorKeyPair; use crate::sync_status::SyncStatusTracker; @@ -409,12 +409,11 @@ impl BlockChainServer { // Schedule the early-aggregation window check. This tick is // one interval before T2, so the timer fires right as the - // window opens at T2 - `early_aggregation_window`. + // window opens at T2 - EARLY_AGGREGATION_WINDOW. if is_aggregator { - let ms_per_interval = time_config.milliseconds_per_interval(); send_after( - Duration::from_millis(ms_per_interval) - - early_aggregation_window(ms_per_interval), + Duration::from_millis(time_config.milliseconds_per_interval()) + - EARLY_AGGREGATION_WINDOW, ctx.clone(), EarlyAggregationCheck, ); @@ -577,7 +576,7 @@ impl BlockChainServer { } /// Early-aggregation trigger: start the slot's session ahead of the - /// interval-2 tick when, inside the window `[T2 - early_aggregation_window, T2)`, + /// interval-2 tick when, inside the window `[T2 - EARLY_AGGREGATION_WINDOW, T2)`, /// a single attestation-data group already holds 2/3 of the signatures /// expected from this node's aggregation subnets. Called after every /// stored current-slot gossip signature and once at the window opening via @@ -592,7 +591,7 @@ impl BlockChainServer { return; } // Only fire inside the early-aggregation window - // `[T2 - early_aggregation_window, T2)`, where T2 is the current + // `[T2 - EARLY_AGGREGATION_WINDOW, T2)`, where T2 is the current // slot's interval-2 boundary; the slot is derived from the wall clock. let time_config = *self.store.config(); let Some(ms_since_genesis) = unix_now_ms().checked_sub(time_config.genesis_time_ms()) @@ -602,7 +601,7 @@ impl BlockChainServer { let ms_per_interval = time_config.milliseconds_per_interval(); let ms_into_slot = ms_since_genesis % time_config.milliseconds_per_slot; let t2_offset = 2 * ms_per_interval; - let window_ms = early_aggregation_window(ms_per_interval).as_millis() as u64; + let window_ms = EARLY_AGGREGATION_WINDOW.as_millis() as u64; if ms_into_slot < t2_offset - window_ms || ms_into_slot >= t2_offset { return; } @@ -1609,19 +1608,6 @@ mod tests { assert_eq!(ms_until_next_interval(genesis_ms - 500, &config), 500); } - /// The window is subtracted from an interval offset in two places, so it - /// must never exceed one interval however short the configured slot is. - #[test] - fn early_aggregation_window_never_exceeds_one_interval() { - for milliseconds_per_slot in [1_000, 2_000, DEFAULT_MILLISECONDS_PER_SLOT, 8_000] { - let ms_per_interval = config(milliseconds_per_slot).milliseconds_per_interval(); - assert!( - early_aggregation_window(ms_per_interval).as_millis() as u64 <= ms_per_interval, - "window overflows the interval at {milliseconds_per_slot} ms per slot" - ); - } - } - #[test] fn aggregation_deadline_is_one_interval() { let config = config(8_000); diff --git a/crates/common/types/src/constants.rs b/crates/common/types/src/constants.rs index ec911713..8b74ec4a 100644 --- a/crates/common/types/src/constants.rs +++ b/crates/common/types/src/constants.rs @@ -23,3 +23,17 @@ pub const INTERVALS_PER_SLOT: u64 = 5; /// Matches the spec's `SECONDS_PER_SLOT = 4`, so a config file written before /// the key existed keeps the cadence it was running. pub const DEFAULT_MILLISECONDS_PER_SLOT: u64 = 4_000; + +/// Shortest slot duration a network's config file may ask for. +/// +/// The knob exists to slow a network down, not to speed it up. Timings that +/// are fixed in milliseconds rather than expressed as a fraction of the slot +/// — notably `EARLY_AGGREGATION_WINDOW` in `ethlambda-blockchain`, which is +/// subtracted from an interval offset — are sized against the spec cadence and +/// stay in range for every slot duration at or above this floor. Below it they +/// would underflow, and the leanVM proofs a slot has to fit do not get any +/// cheaper either. +/// +/// Equal to [`DEFAULT_MILLISECONDS_PER_SLOT`]: the spec cadence is both the +/// value a config file gets by omitting the key and the fastest it may pick. +pub const MIN_MILLISECONDS_PER_SLOT: u64 = DEFAULT_MILLISECONDS_PER_SLOT; diff --git a/crates/common/types/src/genesis.rs b/crates/common/types/src/genesis.rs index d7460782..84091adc 100644 --- a/crates/common/types/src/genesis.rs +++ b/crates/common/types/src/genesis.rs @@ -1,7 +1,9 @@ use serde::Deserialize; use crate::chain_config::ChainConfig; -use crate::constants::{DEFAULT_MILLISECONDS_PER_SLOT, INTERVALS_PER_SLOT}; +use crate::constants::{ + DEFAULT_MILLISECONDS_PER_SLOT, INTERVALS_PER_SLOT, MIN_MILLISECONDS_PER_SLOT, +}; use crate::state::{State, Validator, ValidatorPubkeyBytes}; /// Ways a state can fail to belong to the configured genesis. @@ -112,11 +114,14 @@ fn default_milliseconds_per_slot() -> u64 { } /// Parse `MILLISECONDS_PER_SLOT`, rejecting values the interval grid cannot -/// represent. +/// represent and cadences faster than the client is built for. /// /// A slot is cut into [`INTERVALS_PER_SLOT`] equal intervals and every duty is /// scheduled off that boundary, so a duration that does not divide evenly would /// leave the last interval short and drift the grid against the wall clock. +/// [`MIN_MILLISECONDS_PER_SLOT`] is the other bound: the knob is there to slow +/// a network down, and the timings the client fixes in milliseconds rather than +/// as a fraction of the slot assume a slot no shorter than that. fn deser_milliseconds_per_slot<'de, D>(d: D) -> Result where D: serde::Deserializer<'de>, @@ -124,9 +129,10 @@ where use serde::de::Error; let ms = u64::deserialize(d)?; - if ms == 0 || ms % INTERVALS_PER_SLOT != 0 { + if ms < MIN_MILLISECONDS_PER_SLOT || ms % INTERVALS_PER_SLOT != 0 { return Err(D::Error::custom(format!( - "MILLISECONDS_PER_SLOT is {ms}; expected a positive multiple of {INTERVALS_PER_SLOT}" + "MILLISECONDS_PER_SLOT is {ms}; expected a multiple of {INTERVALS_PER_SLOT} \ + no smaller than {MIN_MILLISECONDS_PER_SLOT}" ))); } Ok(ms) @@ -412,6 +418,31 @@ GENESIS_VALIDATORS: assert!(serde_yaml_ng::from_str::(&yaml).is_err()); } + /// A whole number of intervals is not enough on its own: the client's + /// fixed-millisecond timings need the slot to be at least + /// [`MIN_MILLISECONDS_PER_SLOT`] wide. + #[test] + fn slot_duration_must_not_be_faster_than_the_minimum() { + let too_fast = MIN_MILLISECONDS_PER_SLOT - INTERVALS_PER_SLOT; + let yaml = format!("MILLISECONDS_PER_SLOT: {too_fast}\n{TEST_CONFIG_YAML}"); + let err = serde_yaml_ng::from_str::(&yaml).unwrap_err(); + + assert!( + err.to_string() + .contains(&format!("MILLISECONDS_PER_SLOT is {too_fast}")), + "unexpected error: {err}" + ); + } + + #[test] + fn slot_duration_accepts_the_minimum_itself() { + let yaml = + format!("MILLISECONDS_PER_SLOT: {MIN_MILLISECONDS_PER_SLOT}\n{TEST_CONFIG_YAML}"); + let config: GenesisConfig = serde_yaml_ng::from_str(&yaml).unwrap(); + + assert_eq!(config.milliseconds_per_slot, MIN_MILLISECONDS_PER_SLOT); + } + #[test] fn verify_time_config_accepts_the_config_it_describes() { let config = test_config(); diff --git a/docs/slots_and_intervals.md b/docs/slots_and_intervals.md index aba0f844..e658b378 100644 --- a/docs/slots_and_intervals.md +++ b/docs/slots_and_intervals.md @@ -3,7 +3,9 @@ A Lean Chain slot is divided in 5 intervals of equal length. Every duty a validator owes the chain is due in one of them. The slot lasts 4 seconds by default, so the offsets below are the 800 ms grid; a network that sets `MILLISECONDS_PER_SLOT` in its -config file scales every offset by the same factor: +config file scales every offset by the same factor. That key can only stretch the +grid: 4 seconds is also the floor, since a few client timings are fixed in +milliseconds rather than expressed as a fraction of the slot. | Interval | Offset | Duty | Who acts | What it publishes | | --- | --- | --- | --- | --- | From 178682a19215cb6303110e0d8b72a48e69d515f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:31:04 -0300 Subject: [PATCH 3/4] refactor(blockchain): derive slot starts from SlotInterval `propose_block` and `get_proposal_head` each hand-rolled the slot-start formula that `SlotInterval::to_ms_since_genesis` already centralizes. `SlotInterval::BlockPublication` is the same value (interval index 0), and it is what the adjacent aggregation-deadline computation already uses, so the time grid stays encoded in one place if its shape changes. --- crates/blockchain/src/lib.rs | 4 ++-- crates/blockchain/src/store.rs | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 78f17325..9e2d4059 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -726,8 +726,8 @@ impl BlockChainServer { info!(%slot, %validator_id, "We are the proposer for this slot"); let time_config = *self.store.config(); - let slot_start_ms = - time_config.genesis_time_ms() + slot * time_config.milliseconds_per_slot; + let slot_start_ms = time_config.genesis_time_ms() + + SlotInterval::BlockPublication.to_ms_since_genesis(slot, &time_config); // Build the block. `produce_block_with_signatures` advances the store to // this slot's interval 0 (accepting attestations) before building — one diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 216c322c..010d8ff6 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -885,8 +885,9 @@ pub fn produce_attestation_data(store: &Store, slot: u64) -> AttestationData { /// before returning the canonical head. fn get_proposal_head(store: &mut Store, slot: u64) -> H256 { // Calculate time corresponding to this slot - let slot_time_ms = - store.config().genesis_time_ms() + slot * store.config().milliseconds_per_slot; + let config = *store.config(); + let slot_time_ms = config.genesis_time_ms() + + SlotInterval::BlockPublication.to_ms_since_genesis(slot, &config); // Advance time to current slot (ticking intervals) on_tick(store, slot_time_ms, true); From af4983d7f5a0936c24b3ef25b9677af5ea663fea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:31:35 -0300 Subject: [PATCH 4/4] fix(blockchain): bound the gossip slot before observing arrival metrics `Handler` and `Handler` observe the arrival metrics before `on_block` / `on_gossip_attestation` validate anything, so a gossip-supplied slot reached `to_ms_since_genesis` unchecked. A hostile slot near `u64::MAX` wrapped the `slot * milliseconds_per_slot` product in release builds (the workspace profiles leave overflow-checks off) and panicked the BlockChain actor in debug ones; short of overflow, any far-future slot still poisoned the histogram's sum and the `position` label for the life of the process. Gate both observations on the same future bound the two validators already reject on, so the delta stays a timeliness measurement rather than an attacker-chosen number. `to_ms_since_genesis` also saturates now, so no future caller can reintroduce the panic at that line. --- crates/blockchain/src/lib.rs | 57 +++++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 9e2d4059..8d678e22 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -121,7 +121,13 @@ impl SlotInterval { Self::SafeTargetUpdate => 3, Self::EndOfSlot => 4, }; - slot * config.milliseconds_per_slot + interval * config.milliseconds_per_interval() + // Saturating so a caller that has not yet bounded `slot` (arrival + // metrics see gossip slots before validation) cannot panic the actor in + // a debug build or wrap into a small timestamp in a release one. The + // clamped value is still meaningless: callers wanting a usable delta + // must bound the slot themselves. + slot.saturating_mul(config.milliseconds_per_slot) + .saturating_add(interval * config.milliseconds_per_interval()) } } @@ -1290,6 +1296,22 @@ impl BlockChainServer { metrics::set_node_sync_status(status); self.sync_status_controller.set(status); } + + /// Whether `slot` is close enough to the store clock for its arrival to be + /// worth measuring. + /// + /// Arrival metrics are observed before `on_block` / + /// `on_gossip_attestation` validate anything, so a gossip-supplied slot + /// reaches them unchecked. Reuse the same future bound both validators + /// reject on: past it the delta is not a timeliness measurement but an + /// attacker-chosen number, and one fabricated far-future slot would + /// dominate the histogram's sum and mislabel its `position` bucket for the + /// lifetime of the process. + fn is_arrival_observable(&self, slot: u64) -> bool { + let slot_start_interval = slot.saturating_mul(INTERVALS_PER_SLOT); + let store_time = self.store.time().expect("store time exists"); + slot_start_interval <= store_time + GOSSIP_DISPARITY_INTERVALS + } } // Protocol trait for internal messages only (tick scheduling). @@ -1402,7 +1424,9 @@ impl Handler for BlockChainServer { slot, block: msg.block.message.hash_tree_root(), }); - metrics::observe_gossip_block_arrival(arrival_ms, self.store.config(), slot); + if self.is_arrival_observable(slot) { + metrics::observe_gossip_block_arrival(arrival_ms, self.store.config(), slot); + } } self.on_block(msg.block); } @@ -1411,17 +1435,16 @@ impl Handler for BlockChainServer { impl Handler for BlockChainServer { async fn handle(&mut self, msg: NewAttestation, ctx: &Context) { let arrival_ms = unix_now_ms(); - metrics::observe_gossip_attestation_arrival( - arrival_ms, - self.store.config(), - msg.attestation.data.slot, - ); + let data_slot = msg.attestation.data.slot; + if self.is_arrival_observable(data_slot) { + metrics::observe_gossip_attestation_arrival(arrival_ms, self.store.config(), data_slot); + } self.on_gossip_attestation(&msg.attestation); // Early aggregation only advances the current slot's group counts, so a // late- or future-slot attestation can never cross the threshold; skip // the check unless this attestation is for the store's current slot. let current_slot = self.store.current_slot(); - if msg.attestation.data.slot == current_slot { + if data_slot == current_slot { self.maybe_start_early_aggregation(ctx).await; } } @@ -1562,6 +1585,24 @@ mod tests { } } + #[test] + fn a_hostile_slot_saturates_instead_of_overflowing() { + let config = config(DEFAULT_MILLISECONDS_PER_SLOT); + + // Arrival metrics reach `to_ms_since_genesis` with an unvalidated + // gossip slot, so neither the multiply nor the interval offset may + // panic in a debug build or wrap in a release one. + for interval in [ + SlotInterval::BlockPublication, + SlotInterval::AttestationProduction, + SlotInterval::Aggregation, + SlotInterval::SafeTargetUpdate, + SlotInterval::EndOfSlot, + ] { + assert_eq!(interval.to_ms_since_genesis(u64::MAX, &config), u64::MAX); + } + } + #[test] fn interval_conversions_round_trip() { let config = config(8_000);