Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,16 @@ subspecifications that the Lean Ethereum protocol relies on.
or any on-the-wire string. Rename the Python identifier, never the serialized contract.
- When a fully-expanded name becomes unwieldy, prefer a shorter but still complete phrasing
(drop redundant words) rather than re-introducing an abbreviation.
- **CRITICAL - TEST STRUCTURE MIRRORS SOURCE STRUCTURE**: This is a STRICT requirement. The
test tree under `tests/lean_spec/` mirrors the source tree under `src/lean_spec/` one-to-one.
A source module `src/lean_spec/<path>/<name>.py` has its unit tests in
`tests/lean_spec/<path>/test_<name>.py`, and every test file tests the single source module it
mirrors.
- When you MOVE a class or function to a different module, MOVE its tests to the matching test
module in the SAME change. Never leave tests behind in the old location.
- When you CREATE a new source module, its tests go in the mirrored test path, not appended to
an unrelated test file.
- When you DELETE or RENAME a source module, delete or rename its test module to match.
- A test file must never test a type that lives in a different source module. For example, tests
for `Interval` (in `spec/forks/lstar/interval.py`) belong in
`tests/lean_spec/spec/forks/lstar/test_interval.py`, never in `node/chain/test_clock.py`.
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@

from pydantic import Field, model_validator

from lean_spec.node.chain.clock import Interval
from lean_spec.node.chain.clock import SlotClock
from lean_spec.spec.crypto.merkleization import hash_tree_root
from lean_spec.spec.forks import Slot, ValidatorIndex
from lean_spec.spec.forks import Interval, Slot, ValidatorIndex
from lean_spec.spec.forks.lstar.containers import (
AggregatedAttestations,
Block,
Expand All @@ -22,7 +22,6 @@
Validators,
)
from lean_spec.spec.forks.lstar.spec import LstarSpec
from lean_spec.spec.ssz import Uint64

from ..keys import (
XmssKeyManager,
Expand Down Expand Up @@ -278,10 +277,11 @@ def make_fixture(self) -> Self:
else:
assert step.time is not None
# TickStep.time is a Unix timestamp in seconds.
# Convert to intervals since genesis for the store.
target_interval = Interval.from_unix_time(
Uint64(step.time), store.config.genesis_time
)
# The slot clock converts it to intervals since genesis.
target_interval = SlotClock(
genesis_time=store.config.genesis_time,
time_fn=lambda t=step.time: float(t),
).total_intervals()
store, _ = spec.on_tick(
store,
target_interval,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@

from typing import Any, ClassVar

from lean_spec.node.chain.clock import Interval, SlotClock
from lean_spec.node.chain.config import (
from lean_spec.node.chain.clock import SlotClock
from lean_spec.spec.forks import Interval, Slot
from lean_spec.spec.forks.lstar.config import (
INTERVALS_PER_SLOT,
MILLISECONDS_PER_INTERVAL,
SECONDS_PER_SLOT,
)
from lean_spec.spec.forks import Slot
from lean_spec.spec.ssz import Uint64

from .base import BaseConsensusFixture
Expand Down Expand Up @@ -66,10 +66,10 @@ def make_fixture(self) -> "SlotClockTest":

def _make_from_unix_time(self) -> dict[str, Any]:
"""Convert unix timestamp to interval count since genesis."""
unix_seconds = Uint64(self.input["unixSeconds"])
genesis_time = Uint64(self.input["genesisTime"])
interval = Interval.from_unix_time(unix_seconds, genesis_time)
return {"interval": int(interval)}
unix_seconds = float(self.input["unixSeconds"])
clock = SlotClock(genesis_time=genesis_time, time_fn=lambda: unix_seconds)
return {"interval": int(clock.total_intervals())}

def _make_from_slot(self) -> dict[str, Any]:
"""Convert slot number to interval at that slot's start."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@
from collections import defaultdict

from lean_spec.base import CamelModel
from lean_spec.node.chain.clock import Interval
from lean_spec.spec.crypto.merkleization import hash_tree_root
from lean_spec.spec.crypto.xmss.containers import Signature
from lean_spec.spec.forks import AggregationBits, Slot, ValidatorIndex
from lean_spec.spec.forks import AggregationBits, Interval, Slot, ValidatorIndex
from lean_spec.spec.forks.lstar.containers import (
AggregatedAttestation,
AggregatedAttestations,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
from typing import Literal

from lean_spec.base import CamelModel
from lean_spec.node.chain.clock import Interval
from lean_spec.spec.crypto.merkleization import hash_tree_root
from lean_spec.spec.forks import Slot, ValidatorIndex
from lean_spec.spec.forks import Interval, Slot, ValidatorIndex
from lean_spec.spec.forks.lstar.containers import AttestationData, Block, Store
from lean_spec.spec.forks.lstar.spec import LstarSpec
from lean_spec.spec.ssz import ZERO_HASH, Bytes32
Expand Down
2 changes: 1 addition & 1 deletion src/lean_spec/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@

import logging

from lean_spec.node.chain.config import ATTESTATION_COMMITTEE_COUNT
from lean_spec.node.metrics import PrometheusObserver, registry as metrics
from lean_spec.node.networking.client import LiveNetworkEventSource
from lean_spec.node.networking.gossipsub import GossipTopic
from lean_spec.node.node import Node, NodeConfig
from lean_spec.node.observability import set_observer
from lean_spec.spec.forks import SubnetId
from lean_spec.spec.forks.lstar.config import ATTESTATION_COMMITTEE_COUNT

from .bootstrap import NodeBootstrap

Expand Down
3 changes: 1 addition & 2 deletions src/lean_spec/node/chain/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
"""Specifications for chain and consensus parameters."""

from .clock import Interval, SlotClock
from .clock import SlotClock

__all__ = [
"Interval",
"SlotClock",
]
99 changes: 14 additions & 85 deletions src/lean_spec/node/chain/clock.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,4 @@
"""
Slot Clock.

Time-to-slot conversion for Lean Consensus.

The slot clock bridges wall-clock time to the discrete slot-based time
model used by consensus. Every node must agree on slot boundaries to
coordinate block proposals and attestations.
"""
"""Slot clock."""

from __future__ import annotations

Expand All @@ -15,82 +7,24 @@
from dataclasses import dataclass
from time import time as wall_time

from lean_spec.spec.ssz import Uint64

from .config import (
INTERVALS_PER_SLOT,
from lean_spec.spec.forks import Interval, Slot
from lean_spec.spec.forks.lstar.config import (
MILLISECONDS_PER_INTERVAL,
MILLISECONDS_PER_SLOT,
SECONDS_PER_SLOT,
)


class Interval(Uint64):
"""Interval count since genesis (matches `Store.time`)."""

@classmethod
def from_unix_time(cls, unix_seconds: Uint64, genesis_time: Uint64) -> Interval:
"""
Convert a Unix timestamp to a total interval count since genesis.

Useful when external inputs provide absolute timestamps but the
store tracks time as intervals (800 ms each, 5 per slot).

Args:
unix_seconds: Absolute Unix timestamp in seconds.
genesis_time: Genesis Unix timestamp in seconds.

Returns:
Total intervals elapsed between genesis and the given time.
"""
# Convert the elapsed seconds to milliseconds.
#
# The store measures time at sub-second granularity (800 ms intervals),
# so second-precision input must be scaled up before dividing.
delta_ms = (unix_seconds - genesis_time) * Uint64(1000)

# Truncate to whole intervals.
return cls(delta_ms // MILLISECONDS_PER_INTERVAL)

@classmethod
def from_slot(cls, slot: Slot) -> Interval: # noqa: F821 # ty: ignore[unresolved-reference]
"""
Convert a slot number to the interval at that slot's start.

Each slot spans a fixed number of intervals.
This gives the first interval of the given slot.

Args:
slot: Slot number since genesis.

Returns:
Interval count at the start of the given slot.
"""
# Slot boundaries fall on exact multiples of the interval count.
return cls(int(slot) * int(INTERVALS_PER_SLOT))
from lean_spec.spec.ssz import Uint64


@dataclass(frozen=True, slots=True)
class SlotClock:
"""
Converts wall-clock time to consensus slots and intervals.

All time values are in seconds (Unix timestamps).
"""
"""Converts wall-clock time to consensus slots and intervals."""

genesis_time: Uint64
"""Unix timestamp (seconds) when slot 0 began."""

time_fn: Callable[[], float] = wall_time
"""Time source function (injectable for testing)."""

def _seconds_since_genesis(self) -> Uint64:
"""Seconds elapsed since genesis (0 if before genesis)."""
now = self.current_time()
if now < self.genesis_time:
return Uint64(0)
return now - self.genesis_time

def _milliseconds_since_genesis(self) -> Uint64:
"""Milliseconds elapsed since genesis (0 if before genesis)."""
now_ms = int(self.time_fn() * 1000)
Expand All @@ -99,23 +33,17 @@ def _milliseconds_since_genesis(self) -> Uint64:
return Uint64(0)
return Uint64(now_ms - genesis_ms)

def current_slot(self) -> Slot: # noqa: F821 # ty: ignore[unresolved-reference]
def current_slot(self) -> Slot:
"""Get the current slot number (0 if before genesis)."""
from lean_spec.spec.forks import Slot

return Slot(self._seconds_since_genesis() // SECONDS_PER_SLOT)
return Slot(self._milliseconds_since_genesis() // MILLISECONDS_PER_SLOT)

def current_interval(self) -> Interval:
"""Get the current interval within the slot (0-4)."""
milliseconds_into_slot = self._milliseconds_since_genesis() % MILLISECONDS_PER_SLOT
return Interval(milliseconds_into_slot // MILLISECONDS_PER_INTERVAL)

def total_intervals(self) -> Interval:
"""
Get total intervals elapsed since genesis.

This is the value expected by our store time type.
"""
"""Get total intervals elapsed since genesis."""
return Interval(self._milliseconds_since_genesis() // MILLISECONDS_PER_INTERVAL)

def current_time(self) -> Uint64:
Expand All @@ -126,22 +54,23 @@ def seconds_until_next_interval(self) -> float:
"""
Calculate seconds until the next interval boundary.

Returns time until genesis if before genesis.
Returns 0.0 if exactly at an interval boundary.
- Returns time until genesis if called before genesis.
- Returns a full interval when exactly on a boundary, never zero.
"""
now = self.time_fn()
genesis = int(self.genesis_time)
elapsed = now - genesis

if elapsed < 0:
# Before genesis - return time until genesis.
return -elapsed

# Convert to milliseconds and find time into current interval.
# Position within the current interval, in milliseconds.
elapsed_ms = int(elapsed * 1000)
time_into_interval_ms = elapsed_ms % int(MILLISECONDS_PER_INTERVAL)

# Time until next boundary (may be 0 if exactly at boundary).
# Remaining milliseconds until the boundary.
#
# A full interval when already exactly on a boundary.
ms_until_next = int(MILLISECONDS_PER_INTERVAL) - time_into_interval_ms
return ms_until_next / 1000.0

Expand Down
6 changes: 3 additions & 3 deletions src/lean_spec/node/chain/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@
import logging
from dataclasses import dataclass, field

from lean_spec.node.chain.config import INTERVALS_PER_SLOT
from lean_spec.node.sync import SyncService
from lean_spec.spec.forks import LstarSpec, SignedAggregatedAttestation
from lean_spec.spec.forks import Interval, LstarSpec, SignedAggregatedAttestation
from lean_spec.spec.forks.lstar.config import INTERVALS_PER_SLOT

from .clock import Interval, SlotClock
from .clock import SlotClock

logger = logging.getLogger(__name__)

Expand Down
2 changes: 1 addition & 1 deletion src/lean_spec/node/networking/gossipsub/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@
from __future__ import annotations

from lean_spec.base import StrictBaseModel
from lean_spec.node.chain.config import JUSTIFICATION_LOOKBACK_SLOTS, SECONDS_PER_SLOT
from lean_spec.node.networking.config import GOSSIPSUB_DEFAULT_PROTOCOL_ID
from lean_spec.node.networking.types import ProtocolId
from lean_spec.spec.forks.lstar.config import JUSTIFICATION_LOOKBACK_SLOTS, SECONDS_PER_SLOT


class GossipsubParameters(StrictBaseModel):
Expand Down
4 changes: 2 additions & 2 deletions src/lean_spec/node/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@

from lean_spec.node.api import AggregatorController, ApiServer, ApiServerConfig
from lean_spec.node.chain import SlotClock
from lean_spec.node.chain.clock import Interval
from lean_spec.node.chain.config import ATTESTATION_COMMITTEE_COUNT
from lean_spec.node.chain.service import ChainService
from lean_spec.node.metrics import registry as metrics
from lean_spec.node.networking import NetworkService
Expand All @@ -36,6 +34,7 @@
Block,
BlockBody,
ForkProtocol,
Interval,
LstarSpec,
SignedAttestation,
SignedBlock,
Expand All @@ -44,6 +43,7 @@
ValidatorIndex,
Validators,
)
from lean_spec.spec.forks.lstar.config import ATTESTATION_COMMITTEE_COUNT
from lean_spec.spec.ssz import Bytes32, Uint64

logger = logging.getLogger(__name__)
Expand Down
3 changes: 2 additions & 1 deletion src/lean_spec/node/validator/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,15 @@
from dataclasses import dataclass, field
from typing import Literal

from lean_spec.node.chain.clock import Interval, SlotClock
from lean_spec.node.chain.clock import SlotClock
from lean_spec.node.sync import SyncService
from lean_spec.spec.crypto.merkleization import hash_tree_root
from lean_spec.spec.crypto.xmss import TARGET_SIGNATURE_SCHEME
from lean_spec.spec.crypto.xmss.containers import PublicKey, Signature
from lean_spec.spec.forks import (
AttestationData,
Block,
Interval,
LstarSpec,
SignedAttestation,
SignedBlock,
Expand Down
2 changes: 2 additions & 0 deletions src/lean_spec/spec/forks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
ValidatorIndices,
Validators,
)
from .lstar.interval import Interval
from .lstar.spec import LstarSpec, LstarStore
from .protocol import ForkProtocol, SpecStateType, SpecStoreType
from .registry import ForkRegistry
Expand Down Expand Up @@ -55,6 +56,7 @@
"ForkProtocol",
"ForkRegistry",
"IMMEDIATE_JUSTIFICATION_WINDOW",
"Interval",
"LstarSpec",
"LstarStore",
"SignedAggregatedAttestation",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,7 @@
"""
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 a 4-second slot, this corresponds to a history of approximately 12.1 days.
"""

ATTESTATION_COMMITTEE_COUNT: Final = Uint64(1)
Expand Down
4 changes: 2 additions & 2 deletions src/lean_spec/spec/forks/lstar/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@

from lean_spec.base import StrictBaseModel
from lean_spec.config import LEAN_ENV
from lean_spec.node.chain.clock import Interval
from lean_spec.node.chain.config import HISTORICAL_ROOTS_LIMIT
from lean_spec.spec.crypto.xmss.containers import PublicKey, Signature
from lean_spec.spec.forks.lstar.config import HISTORICAL_ROOTS_LIMIT
from lean_spec.spec.ssz import Boolean, ByteList512KiB, Bytes32, Bytes52, Container, SSZList, Uint64
from lean_spec.spec.ssz.bitfields import BaseBitlist

from .interval import Interval
from .slot import IMMEDIATE_JUSTIFICATION_WINDOW, Slot

__all__ = [
Expand Down
Loading
Loading