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
3 changes: 3 additions & 0 deletions src/lean_spec/subspecs/chain/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
61 changes: 61 additions & 0 deletions src/lean_spec/subspecs/chain/clock.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions src/lean_spec/subspecs/containers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
)
from .checkpoint import Checkpoint
from .config import Config
from .slot import Slot
from .state import State
from .validator import Validator

Expand All @@ -38,6 +39,7 @@
"Config",
"SignedAttestation",
"SignedBlockWithAttestation",
"Slot",
"State",
"Validator",
]
110 changes: 110 additions & 0 deletions tests/lean_spec/subspecs/chain/test_clock.py
Original file line number Diff line number Diff line change
@@ -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)
Loading