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: 1 addition & 2 deletions src/lean_spec/subspecs/chain/clock.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""
Slot Clock
==========
Slot Clock.

Time-to-slot conversion for Lean Consensus.

Expand Down
4 changes: 0 additions & 4 deletions src/lean_spec/subspecs/chain/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@

from lean_spec.types.uint import Uint64

# --- Time Parameters ---

INTERVALS_PER_SLOT = Uint64(5)
"""Number of intervals per slot for forkchoice processing."""

Expand All @@ -21,8 +19,6 @@
JUSTIFICATION_LOOKBACK_SLOTS: Final = Uint64(3)
"""The number of slots to lookback for justification."""

# --- State List Length Presets ---

HISTORICAL_ROOTS_LIMIT: Final = Uint64(2**18)
"""
The maximum number of historical block roots to store in the state.
Expand Down
23 changes: 11 additions & 12 deletions src/lean_spec/subspecs/chain/service.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
"""
Chain service that drives consensus timing.

The Chain Problem
-----------------
Ethereum consensus runs on a clock. Every 4 seconds (1 slot), validators:
- Interval 0: Propose blocks
- Interval 1: Create attestations
- Interval 2: Update safe target
- Interval 3: Accept attestations into fork choice
Ethereum consensus runs on a clock. Every 4 seconds (1 slot), validators act
at each of the 5 intervals:

- Interval 0: Block proposal
- Interval 1: Vote propagation
- Interval 2: Aggregation
- Interval 3: Safe target update
- Interval 4: Attestation acceptance into fork choice

The Store has all this logic built in. But nothing drives the clock.
ChainService is that driver - a simple timer loop.
ChainService is that driver β€” a simple timer loop:

How It Works
------------
1. Sleep until next interval boundary
2. Get current wall-clock time
3. Tick the store forward to current time
Expand Down Expand Up @@ -154,8 +153,8 @@ async def _tick_to(self, target_interval: Interval) -> list[SignedAggregatedAtte
Between each remaining interval tick, yields to the event loop so
gossip messages can be processed.

Updates ``self.sync_service.store`` in place after each tick so
concurrent gossip handlers see current time.
Updates the sync service's store after each tick so concurrent
gossip handlers see current time.

Returns aggregated attestations produced during the ticks.
"""
Expand Down
6 changes: 3 additions & 3 deletions src/lean_spec/subspecs/sync/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@
Sync service for the lean Ethereum consensus client.

What Is Sync?
-------------

When an Ethereum node starts, it needs to catch up with the network. The
chain may be millions of blocks ahead. Sync is the process of downloading
and validating those blocks until the node reaches the chain head.

The Challenge
-------------

Sync is harder than it sounds:

1. **Ordering**: Blocks reference parents; children arrive before parents
2. **Unreliable peers**: Some peers are slow, some are malicious
3. **Progress tracking**: Need to know when we are "done"

How It Works
------------

- Blocks arrive via gossip subscription
- If parent is known, process immediately
- If parent is unknown, cache block and fetch parent (backfill)
Expand Down
31 changes: 10 additions & 21 deletions src/lean_spec/subspecs/sync/backfill_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
is called "backfill" because we are filling in gaps going backward in time.

The Challenge
-------------

Blocks can arrive out of order for several reasons:

1. **Gossip timing**: A child block gossips faster than its parent
Expand All @@ -17,7 +17,7 @@
resolve them once their parents arrive or are explicitly fetched.

How It Works
------------

1. Track orphan blocks in the BlockCache
2. When an orphan is detected, request its parent from peers
3. If the fetched parent is also an orphan, request its parent
Expand All @@ -28,7 +28,7 @@
and handles dynamic gaps naturally.

Depth Limiting
--------------

Backfill depth is limited to prevent attacks and resource exhaustion:

- An attacker could send a block claiming to have a parent millions of slots ago
Expand Down Expand Up @@ -92,23 +92,23 @@ class BackfillSync:
arrive with unknown parents, this class orchestrates fetching those parents.

How It Works
------------

1. **Detection**: BlockCache marks blocks as orphans when added
2. **Request**: BackfillSync requests missing parents from peers
3. **Recursion**: If fetched parents are also orphans, continue fetching
4. **Resolution**: When parent chain is complete, blocks become processable

Integration
-----------

BackfillSync does not process blocks itself. It only ensures parents exist
in the BlockCache. The SyncService is responsible for:

- Calling `fill_missing()` when orphans are detected
- Triggering backfill when orphans are detected
- Processing blocks when they become processable
- Integrating blocks into the Store

Thread Safety
-------------

This class is designed for single-threaded async operation. The `_pending`
set tracks in-flight requests to avoid duplicate fetches.
"""
Expand Down Expand Up @@ -208,15 +208,15 @@ async def _fetch_batch(
)

if blocks:
# Request succeeded.
# Request succeeded with data.
self.peer_manager.on_request_success(peer.peer_id)

# Add blocks to cache and check for further orphans.
await self._process_received_blocks(blocks, peer.peer_id, depth)
else:
# Empty response. Peer may not have the blocks.
# This is not necessarily a failure (blocks may not exist).
pass
# Still a completed request β€” release the in-flight slot.
self.peer_manager.on_request_success(peer.peer_id)

except Exception:
# Network error.
Expand Down Expand Up @@ -264,17 +264,6 @@ async def _process_received_blocks(
if new_orphan_parents:
await self.fill_missing(new_orphan_parents, depth=depth + 1)

async def fill_all_orphans(self) -> None:
"""
Fetch parents for all current orphan blocks.

Convenience method that fetches the parent roots of all blocks
currently marked as orphans in the cache.
"""
orphan_parents = self.block_cache.get_orphan_parents()
if orphan_parents:
await self.fill_missing(orphan_parents)

def reset(self) -> None:
"""Clear all pending state."""
self._pending.clear()
26 changes: 4 additions & 22 deletions src/lean_spec/subspecs/sync/block_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Block cache for downloaded blocks awaiting parent resolution.

Why Cache Blocks?
-----------------

In an ideal world, blocks arrive in perfect order: parent before child, always.
Reality differs. Network latency, parallel downloads, and gossip propagation
mean blocks often arrive before their parents are known.
Expand All @@ -16,7 +16,7 @@
arrives, then process both.

How It Works
------------

The cache maintains three data structures:

1. **Block storage**: Maps block root to PendingBlock (the block + metadata)
Expand All @@ -31,7 +31,7 @@
4. Recursively check if processed children have their own waiting children

Memory Safety
-------------

The cache is bounded by MAX_CACHED_BLOCKS (1024). When full, FIFO eviction
removes the oldest blocks. This prevents memory exhaustion from attacks or
prolonged network partitions that could otherwise grow the cache unboundedly.
Expand Down Expand Up @@ -251,7 +251,7 @@ def remove(self, root: Bytes32) -> PendingBlock | None:
return None

# Clean up orphan tracking.
self._orphans.discard(root)
self.unmark_orphan(root)

# Clean up parent index.
#
Expand Down Expand Up @@ -374,29 +374,11 @@ def get_processable(self, store: "Store") -> list[PendingBlock]:
# Sort ensures parent-before-child processing order.
return sorted(processable, key=lambda p: p.slot)

def get_highest_slot(self) -> Slot | None:
"""
Get the highest slot among cached blocks.

This is useful for progress reporting without exposing internal storage.

Returns:
The highest slot in the cache, or None if the cache is empty.
"""
if not self._blocks:
return None
return max(p.slot for p in self._blocks.values())

@property
def orphan_count(self) -> int:
"""Number of orphan blocks in the cache."""
return len(self._orphans)

@property
def is_empty(self) -> bool:
"""Check if the cache is empty."""
return len(self._blocks) == 0

def clear(self) -> None:
"""Remove all blocks from the cache."""
self._blocks.clear()
Expand Down
3 changes: 1 addition & 2 deletions src/lean_spec/subspecs/sync/checkpoint_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,7 @@ async def verify_checkpoint_state(state: "State") -> bool:
# If the data was corrupted, hashing will likely fail or produce
# an unexpected result. We log the root for debugging.
state_root = hash_tree_root(state)
root_preview = state_root.hex()[:16]
logger.info(f"Checkpoint state verified: slot={state.slot}, root={root_preview}...")
logger.info(f"Checkpoint state verified: slot={state.slot}, root={state_root}...")
return True

except Exception as e:
Expand Down
3 changes: 3 additions & 0 deletions src/lean_spec/subspecs/sync/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@

MAX_BACKFILL_DEPTH: Final[int] = 512
"""Maximum depth for backfill parent chain resolution."""

MAX_PENDING_ATTESTATIONS: Final[int] = 1024
"""Maximum buffered attestations awaiting block processing."""
Loading
Loading