Skip to content

feat(config): read the slot duration from the network config file - #598

Open
MegaRedHand wants to merge 3 commits into
mainfrom
feat/configurable-slot-time
Open

feat(config): read the slot duration from the network config file#598
MegaRedHand wants to merge 3 commits into
mainfrom
feat/configurable-slot-time

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

What

config.yaml gains an optional MILLISECONDS_PER_SLOT. It defaults to 4000, the value the whole network already ran on, so an existing config file behaves exactly as before.

GENESIS_TIME: 1770407233
MILLISECONDS_PER_SLOT: 8000   # optional, defaults to 4000
GENESIS_VALIDATORS:
  - attestation_pubkey: "cd32..."
    proposal_pubkey: "b7b0..."

The value 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 duration that does not divide evenly would leave the last interval short and drift the grid against the wall clock.

INTERVALS_PER_SLOT stays a compile-time constant. Each interval carries a distinct validator duty, so the count is part of the protocol rather than a tuning knob.

Why not put it in the state's Config

State.config is SSZ-merkleized into the state root, so its layout is fixed by the spec and cannot gain a field without changing every state root and breaking the SSZ fixtures.

So there are now two config types:

Type Where it lives Holds
StateConfig State.config, merkleized genesis_time, unchanged spec layout (this is the old ChainConfig, renamed)
ChainConfig Metadata["config"] in the DB genesis_time + milliseconds_per_slot

Consumers read the second one through Store::config, which already carried the genesis time, so most call sites changed from a constant to a field on something they already had in hand.

What now follows the configured cadence

  • Interval length, the tick grid, and every slot/interval conversion (SlotInterval, ms_until_next_interval, store::on_tick).
  • The aggregation deadline, which was a hardcoded 800 ms whose doc already described it as "one full interval".
  • The early-aggregation window, 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.
  • Gossipsub's duplicate cache, which leanSpec defines as SECONDS_PER_SLOT * JUSTIFICATION_LOOKBACK_SLOTS * 2 and which was pinned at 4 * 3 * 2 seconds.
  • GET /lean/v0/config/spec, which now reports what the node is actually running rather than what it was built with.

Slot-count constants (SNAPSHOT_ANCHOR_INTERVAL, MAX_RESUMABLE_DB_STATE_AGE, the payload buffer caps) are deliberately left alone: their cost is per-slot, not per-second. Their doc comments now say which wall-clock figure assumes the default cadence.

Prometheus histogram buckets are also left alone, since Prometheus fixes them at registration. A network on a different cadence reads the arrival and tick histograms against the default grid; noted in docs/metrics.md and in the bucket definitions.

Compatibility

  • Existing data directories resume. A DB 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 under.
  • A cadence change is refused, not silently applied. Resuming a data directory whose persisted slot duration disagrees with the config file fails 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 ignore the key. They hold the value at compile time (leanSpec's SECONDS_PER_SLOT) and their genesis parsers ignore unknown keys, so setting it only takes effect on a network where every node reads it. Until the spec adopts a config-file key, this is an ethlambda-only knob.

Tests

  • Config parsing: default, override, non-multiple rejected, zero rejected.
  • ChainConfig: SSZ round trip, legacy blob decoding as the default cadence, garbage rejected.
  • Storage: from_db_state rejects a slot-duration mismatch, and resumes a pre-MILLISECONDS_PER_SLOT data directory.
  • Interval grid: offsets scale with the slot duration, conversions round-trip at 8 s, ms_until_next_interval at and around a boundary, the early window never exceeds an interval at any cadence.
  • store::on_tick advances one interval per configured interval at 8 s, rolling the slot over after five.
  • GET /lean/v0/config/spec reports 8000/1600 for a store built at 8 s.

@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

⚠️ Review failed: Kimi API request failed with status 404: Not found the model kimi-k2.5 or Permission denied


Automated review by Kimi (Moonshot AI)

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

  1. High: bin/ethlambda/src/checkpoint_sync.rs:129-137, 154-170, 189-208, 319-326, and bin/ethlambda/src/main.rs:751-776 still validate checkpoint-sync anchors only by GENESIS_TIME plus validator registry. The new slot-duration fingerprint is enforced for persisted DB reuse in crates/storage/src/store.rs:656-671, but not for downloaded anchors. That means a node configured for MILLISECONDS_PER_SLOT = 8000 can accept a checkpoint from a peer on the same genesis/validator set but 4000, then run imported slot numbers on the wrong wall-clock schedule. For consensus code this is a real correctness bug, not just an observability gap. The obvious fix is to fetch and compare the peer’s advertised cadence before accepting the anchor, e.g. via /lean/v0/config/spec exposed at crates/net/rpc/src/spec.rs:23-36, and add a regression test for mismatched-cadence checkpoint peers.

No other blockers stood out in the touched timing/storage paths on static review.

I could not run cargo test here: the sandbox blocks the usual rustup/cargo write and dependency-fetch paths, so this review is source-based only.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review: PR 598 — configurable slot duration

This is a well-scoped refactor: MILLISECONDS_PER_SLOT becomes a network-config-file value, threaded through a new ChainTimeConfig type kept out of the SSZ-merkleized state, with solid backward-compat handling for existing data directories. I traced the full 32-file diff, including every call site of the changed SlotInterval methods, Store::from_anchor_state/get_forkchoice_store, and the gossipsub duplicate-cache computation — all consistently updated, and the invariant that made this refactor necessary (the early-aggregation window potentially exceeding an interval on a short cadence) is now enforced at the single production site (early_aggregation_window) rather than a const assert, with a direct unit test (early_aggregation_window_never_exceeds_one_interval) sweeping several cadences. Good engineering overall — no correctness bugs found in the consensus-critical paths (interval/slot arithmetic, fork-choice store init, genesis mismatch detection).

A few minor points worth considering, none blocking:

  1. ChainTimeConfig doesn't self-validate its invariant (crates/common/types/src/chain_config.rs:1137-1151). The "must be a positive multiple of INTERVALS_PER_SLOT" rule is enforced only in deser_milliseconds_per_slot (crates/common/types/src/genesis.rs:1327-1339), not in ChainTimeConfig::new. Every current call site happens to pass a validated or hardcoded-valid value, so this isn't a live bug, but if a future call site ever constructs ChainTimeConfig from a value that skipped the YAML deserializer (e.g. an RPC-driven config reload, or a programmatically built GenesisConfig), milliseconds_per_interval() would silently truncate via integer division rather than fail loudly. Consider validating in ChainTimeConfig::new too (or making it a TryFrom), so the invariant travels with the type instead of living only at one entry point.

  2. No upper bound on MILLISECONDS_PER_SLOT (crates/common/types/src/genesis.rs:1327-1339). Only zero and non-multiples are rejected. An operator typo (e.g. an extra digit) could produce a value large enough that slot * milliseconds_per_slot risks overflowing u64 at very large slot numbers, though this would take a very long time to matter in practice. Low severity since this is trusted local config, not attacker-supplied network data, but a sanity ceiling (e.g. rejecting anything above some generous multiple of the default) would be cheap insurance.

  3. Downgrade path is a hard panic, not mentioned in the PR description: if a data directory is written by this build (16-byte ChainTimeConfig under Metadata["config"]) and then opened by a pre-PR binary, the old ChainConfig::from_ssz_bytes(&bytes).expect("valid config") will fail to decode the longer blob and panic rather than error gracefully. This is arguably fine (fail loud beats silently running the wrong cadence) and is a pre-existing .expect() pattern, but it's a real operational trap during a rollback — worth a line in RELEASE.md or the PR notes if downgrades are ever expected to be supported.

Nothing else stood out — the SSZ round-trip/legacy-decode tests for ChainTimeConfig, the GenesisMismatch::SlotDuration check placed correctly ahead of the state-based validator-registry check (since the state itself can't see slot duration), and the Store::from_db_state mismatch test all look correct and well-targeted.


Automated review by Claude (Anthropic) · sonnet · custom prompt

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.
@MegaRedHand
MegaRedHand force-pushed the feat/configurable-slot-time branch from 9a38f2d to 436b9c1 Compare August 31, 2026 15:25
Brings in the offline block-building benchmark sub-command (#595).

Conflicts and adaptations:
- `NEW_PAYLOAD_CAP` (crates/storage/src/store.rs): main made it `pub` for the
  benchmark's pool seeding; this branch dropped the hardcoded "~4s" from its doc
  comment because the slot duration is now configurable. Kept both.
- `benchmark::corpus` builds its synthetic store through
  `Store::from_anchor_state`, which now takes the slot duration; the harness
  derives tick timestamps from slot numbers rather than a clock, so it passes
  `DEFAULT_MILLISECONDS_PER_SLOT`.
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.
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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny dedup opportunity: this hand-rolls the slot-start formula that SlotInterval already centralizes — SlotInterval::BlockPublication.to_ms_since_genesis(slot, &time_config) is a value-identical drop-in (interval index 0), and it's what the adjacent t2_ms computation already uses via SlotInterval::Aggregation. Same applies to get_proposal_head in store.rs (store.config().genesis_time_ms() + slot * store.config().milliseconds_per_slot), where it would also drop the doubled store.config() call. Keeps the time grid encoded in exactly one place if its shape ever changes.

@pablodeymo

Copy link
Copy Markdown
Collaborator

Not introduced by this PR (the multiply pre-dates it), but since these exact lines are re-plumbed here, noting for a follow-up: Handler<NewBlock> / Handler<NewAttestation> call observe_gossip_*_arrival before on_block / on_gossip_attestation validation, so the gossip-supplied slot reaches to_ms_since_genesis's slot * config.milliseconds_per_slot (crates/blockchain/src/metrics.rs) unbounded. A hostile slot near u64::MAX wraps in release (garbage delta + wrong position label — no panic, since the workspace profiles don't enable overflow-checks) and panics the BlockChain actor in debug builds. A checked_mul or an early slot-sanity bound before observing would close it. Happy to file this as a separate issue if you prefer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants