Skip to content

Commit 8a8b572

Browse files
authored
chain: add ChainService and tests (leanEthereum#268)
1 parent ba7fd15 commit 8a8b572

4 files changed

Lines changed: 477 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ uvx tox # Everything (checks + tests + docs)
4949
- Google docstring style
5050
- Test files/functions must start with `test_`
5151
- **No example code in docstrings**: Do not include `Example:` sections with code blocks in docstrings. Keep documentation concise and focused on explaining *what* and *why*, not *how to use*. Unit tests serve as usage examples.
52+
- **Avoid explicit function names in documentation**: In docstrings and comments, describe behavior using plain language rather than explicit function or method names. Names change over time, making documentation stale. Prefer descriptive sentences like "tick the store forward" instead of referencing the exact API signature.
5253

5354
## Test Framework Structure
5455

src/lean_spec/subspecs/chain/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
from .clock import Interval, SlotClock
44
from .config import DEVNET_CONFIG
5+
from .service import ChainService
56

67
__all__ = [
8+
"ChainService",
79
"DEVNET_CONFIG",
810
"Interval",
911
"SlotClock",
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""
2+
Chain service that drives consensus timing.
3+
4+
The Chain Problem
5+
-----------------
6+
Ethereum consensus runs on a clock. Every 4 seconds (1 slot), validators:
7+
- Interval 0: Propose blocks
8+
- Interval 1: Create attestations
9+
- Interval 2: Update safe target
10+
- Interval 3: Accept attestations into fork choice
11+
12+
The Store has all this logic built in. But nothing drives the clock.
13+
ChainService is that driver - a simple timer loop.
14+
15+
How It Works
16+
------------
17+
1. Sleep until next interval boundary
18+
2. Get current wall-clock time
19+
3. Tick the store forward to current time
20+
4. Update the sync service with the new store state
21+
5. Repeat forever
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import asyncio
27+
from dataclasses import dataclass, field
28+
from typing import TYPE_CHECKING
29+
30+
from lean_spec.types import Uint64
31+
32+
from .clock import SlotClock
33+
from .config import SECONDS_PER_INTERVAL
34+
35+
if TYPE_CHECKING:
36+
from lean_spec.subspecs.sync import SyncService
37+
38+
39+
@dataclass(slots=True)
40+
class ChainService:
41+
"""
42+
Drives the consensus clock by periodically ticking the forkchoice store.
43+
44+
ChainService is the heartbeat of a consensus client. It ensures time
45+
advances in the Store, triggering interval-specific actions like
46+
attestation acceptance and safe target updates.
47+
48+
The service is intentionally minimal:
49+
- Timer loop that wakes every interval
50+
- Ticks the store forward to current time
51+
- Updates the sync service's store reference
52+
"""
53+
54+
sync_service: SyncService
55+
"""Sync service whose store we tick."""
56+
57+
clock: SlotClock
58+
"""Clock for time calculation."""
59+
60+
_running: bool = field(default=False, repr=False)
61+
"""Whether the service is running."""
62+
63+
async def run(self) -> None:
64+
"""
65+
Main loop - tick the store every interval.
66+
67+
This is the core of the chain service. It runs forever, sleeping
68+
until each interval boundary and then advancing the store's time.
69+
70+
The loop continues until the service is stopped.
71+
"""
72+
self._running = True
73+
74+
while self._running:
75+
# Sleep until next interval boundary for precise timing.
76+
#
77+
# Interval boundaries occur every SECONDS_PER_INTERVAL (1 second).
78+
# Sleeping to boundaries ensures consistent tick timing.
79+
await self._sleep_until_next_interval()
80+
81+
# Get current wall-clock time as Unix timestamp.
82+
#
83+
# The store expects an absolute timestamp, not intervals.
84+
# It internally converts to intervals.
85+
current_time = Uint64(int(self.clock._time_fn()))
86+
87+
# Tick the store forward to current time.
88+
#
89+
# The store advances time interval by interval, performing
90+
# appropriate actions at each interval.
91+
#
92+
# This minimal service does not produce blocks.
93+
# Block production requires validator keys.
94+
new_store = self.sync_service.store.on_tick(
95+
time=current_time,
96+
has_proposal=False,
97+
)
98+
99+
# Update sync service's store reference.
100+
#
101+
# SyncService owns the authoritative store. After ticking,
102+
# we update its reference so gossip block processing sees
103+
# the updated time.
104+
self.sync_service.store = new_store
105+
106+
async def _sleep_until_next_interval(self) -> None:
107+
"""
108+
Sleep until the next interval boundary.
109+
110+
Calculates the precise sleep duration to wake up at the start
111+
of the next interval. This ensures tick timing is aligned with
112+
network consensus expectations.
113+
"""
114+
now = self.clock._time_fn()
115+
genesis = int(self.clock.genesis_time)
116+
117+
# Time since genesis in seconds (float for precision).
118+
elapsed = now - genesis
119+
120+
if elapsed < 0:
121+
# Before genesis - sleep until genesis.
122+
await asyncio.sleep(-elapsed)
123+
return
124+
125+
# Current interval number (floored to integer).
126+
current_interval = int(elapsed // int(SECONDS_PER_INTERVAL))
127+
128+
# Next interval boundary in absolute time.
129+
next_boundary = genesis + (current_interval + 1) * int(SECONDS_PER_INTERVAL)
130+
131+
# Sleep duration (may be zero if we're exactly at boundary).
132+
sleep_time = max(0.0, next_boundary - now)
133+
await asyncio.sleep(sleep_time)
134+
135+
def stop(self) -> None:
136+
"""
137+
Stop the service.
138+
139+
Sets the running flag to False, causing the run() loop to exit
140+
after completing its current sleep cycle.
141+
"""
142+
self._running = False
143+
144+
@property
145+
def is_running(self) -> bool:
146+
"""Check if the service is currently running."""
147+
return self._running

0 commit comments

Comments
 (0)