diff --git a/src/lean_spec/subspecs/chain/__init__.py b/src/lean_spec/subspecs/chain/__init__.py index 9ffe8101..bde9841e 100644 --- a/src/lean_spec/subspecs/chain/__init__.py +++ b/src/lean_spec/subspecs/chain/__init__.py @@ -1,7 +1,10 @@ """Specifications for chain and consensus parameters.""" +from .clock import Interval, SlotClock from .config import DEVNET_CONFIG __all__ = [ "DEVNET_CONFIG", + "Interval", + "SlotClock", ] diff --git a/src/lean_spec/subspecs/chain/clock.py b/src/lean_spec/subspecs/chain/clock.py new file mode 100644 index 00000000..1933bbf6 --- /dev/null +++ b/src/lean_spec/subspecs/chain/clock.py @@ -0,0 +1,61 @@ +""" +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. +""" + +from dataclasses import dataclass +from time import time as wall_time +from typing import Callable + +from lean_spec.subspecs.containers import Slot +from lean_spec.types import Uint64 + +from .config import SECONDS_PER_INTERVAL, SECONDS_PER_SLOT + +Interval = Uint64 +"""Interval count since genesis (matches ``Store.time``).""" + + +@dataclass(frozen=True, slots=True) +class SlotClock: + """ + Converts wall-clock time to consensus slots and intervals. + + All time values are in seconds (Unix timestamps). + """ + + 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 = Uint64(int(self._time_fn())) + if now < self.genesis_time: + return Uint64(0) + return now - self.genesis_time + + def current_slot(self) -> Slot: + """Get the current slot number (0 if before genesis).""" + return Slot(self._seconds_since_genesis() // SECONDS_PER_SLOT) + + def current_interval(self) -> Interval: + """Get the current interval within the slot (0-3).""" + seconds_into_slot = self._seconds_since_genesis() % SECONDS_PER_SLOT + return seconds_into_slot // SECONDS_PER_INTERVAL + + def total_intervals(self) -> Interval: + """ + Get total intervals elapsed since genesis. + + This is the value expected by ``Store.time``. + """ + return self._seconds_since_genesis() // SECONDS_PER_INTERVAL diff --git a/src/lean_spec/subspecs/containers/__init__.py b/src/lean_spec/subspecs/containers/__init__.py index 3f42e8f0..47e6c14c 100644 --- a/src/lean_spec/subspecs/containers/__init__.py +++ b/src/lean_spec/subspecs/containers/__init__.py @@ -23,6 +23,7 @@ ) from .checkpoint import Checkpoint from .config import Config +from .slot import Slot from .state import State from .validator import Validator @@ -38,6 +39,7 @@ "Config", "SignedAttestation", "SignedBlockWithAttestation", + "Slot", "State", "Validator", ] diff --git a/tests/lean_spec/subspecs/chain/test_clock.py b/tests/lean_spec/subspecs/chain/test_clock.py new file mode 100644 index 00000000..45e393f6 --- /dev/null +++ b/tests/lean_spec/subspecs/chain/test_clock.py @@ -0,0 +1,110 @@ +"""Tests for the SlotClock time-to-slot converter.""" + +import pytest + +from lean_spec.subspecs.chain import Interval, SlotClock +from lean_spec.subspecs.chain.config import ( + INTERVALS_PER_SLOT, + SECONDS_PER_INTERVAL, + SECONDS_PER_SLOT, +) +from lean_spec.subspecs.containers import Slot +from lean_spec.types import Uint64 + + +class TestCurrentSlot: + """Tests for current_slot().""" + + def test_at_genesis(self) -> None: + """Slot is 0 at exactly genesis time.""" + genesis = Uint64(1700000000) + clock = SlotClock(genesis_time=genesis, _time_fn=lambda: float(genesis)) + assert clock.current_slot() == Slot(0) + + def test_before_genesis(self) -> None: + """Slot is 0 before genesis.""" + genesis = Uint64(1700000000) + clock = SlotClock(genesis_time=genesis, _time_fn=lambda: float(genesis - Uint64(100))) + assert clock.current_slot() == Slot(0) + + def test_progression(self) -> None: + """Slot increments every SECONDS_PER_SLOT seconds.""" + genesis = Uint64(1700000000) + for expected_slot in range(5): + time = genesis + Uint64(expected_slot) * SECONDS_PER_SLOT + clock = SlotClock(genesis_time=genesis, _time_fn=lambda t=time: float(t)) + assert clock.current_slot() == Slot(expected_slot) + + def test_mid_slot(self) -> None: + """Slot remains constant within a slot.""" + genesis = Uint64(1700000000) + time = genesis + Uint64(3) * SECONDS_PER_SLOT + Uint64(2) + clock = SlotClock(genesis_time=genesis, _time_fn=lambda: float(time)) + assert clock.current_slot() == Slot(3) + + +class TestCurrentInterval: + """Tests for current_interval().""" + + def test_at_slot_start(self) -> None: + """Interval is 0 at slot start.""" + genesis = Uint64(1700000000) + clock = SlotClock(genesis_time=genesis, _time_fn=lambda: float(genesis)) + assert clock.current_interval() == Interval(0) + + def test_progression(self) -> None: + """Interval increments every SECONDS_PER_INTERVAL seconds.""" + genesis = Uint64(1700000000) + for expected_interval in range(int(INTERVALS_PER_SLOT)): + time = genesis + Uint64(expected_interval) * SECONDS_PER_INTERVAL + clock = SlotClock(genesis_time=genesis, _time_fn=lambda t=time: float(t)) + assert clock.current_interval() == Interval(expected_interval) + + def test_wraps_at_slot_boundary(self) -> None: + """Interval resets to 0 at next slot.""" + genesis = Uint64(1700000000) + time = genesis + SECONDS_PER_SLOT + clock = SlotClock(genesis_time=genesis, _time_fn=lambda: float(time)) + assert clock.current_interval() == Interval(0) + + +class TestTotalIntervals: + """Tests for total_intervals().""" + + def test_counts_all_intervals(self) -> None: + """total_intervals counts all intervals since genesis.""" + genesis = Uint64(1700000000) + intervals_per_slot = int(INTERVALS_PER_SLOT) + # 3 slots + 2 intervals = 14 total intervals + time = genesis + Uint64(3) * SECONDS_PER_SLOT + Uint64(2) * SECONDS_PER_INTERVAL + clock = SlotClock(genesis_time=genesis, _time_fn=lambda: float(time)) + assert clock.total_intervals() == Interval(3 * intervals_per_slot + 2) + + def test_before_genesis(self) -> None: + """total_intervals is 0 before genesis.""" + genesis = Uint64(1700000000) + clock = SlotClock(genesis_time=genesis, _time_fn=lambda: float(genesis - Uint64(100))) + assert clock.total_intervals() == Interval(0) + + +class TestSlotClockImmutability: + """Tests for SlotClock immutability.""" + + def test_is_frozen(self) -> None: + """SlotClock is immutable.""" + clock = SlotClock(genesis_time=Uint64(1700000000)) + with pytest.raises(AttributeError): + clock.genesis_time = Uint64(1700000001) # type: ignore[misc] + + +class TestReturnTypes: + """Tests for proper return types.""" + + def test_types_are_correct(self) -> None: + """All return types are domain-specific.""" + genesis = Uint64(1700000000) + clock = SlotClock(genesis_time=genesis, _time_fn=lambda: float(genesis)) + + assert isinstance(clock.current_slot(), Slot) + assert isinstance(clock.current_interval(), Uint64) + assert isinstance(clock.total_intervals(), Uint64)