Skip to content

Commit 1e84bd3

Browse files
authored
subspecs: add forkchoice subspec with tests (leanEthereum#53)
* subspecs: add forkchoice subspec with tests * mv constants * small comment touchup * cleanup import stuffs * fix tests with uint * small fix * mv get_vote_target to store * reorganize tests * rm useless duplicate tests * use Bytes32.zero()
1 parent 9231f97 commit 1e84bd3

12 files changed

Lines changed: 2787 additions & 0 deletions

src/lean_spec/subspecs/chain/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,18 @@
1212

1313
# --- Time Parameters ---
1414

15+
INTERVALS_PER_SLOT = Uint64(4)
16+
"""Number of intervals per slot for forkchoice processing."""
17+
1518
SLOT_DURATION_MS: Final = Uint64(4000)
1619
"""The fixed duration of a single slot in milliseconds."""
1720

1821
SECONDS_PER_SLOT: Final = SLOT_DURATION_MS // Uint64(1000)
1922
"""The fixed duration of a single slot in seconds."""
2023

24+
SECONDS_PER_INTERVAL = SECONDS_PER_SLOT // INTERVALS_PER_SLOT
25+
"""Seconds per forkchoice processing interval."""
26+
2127
JUSTIFICATION_LOOKBACK_SLOTS: Final = Uint64(3)
2228
"""The number of slots to lookback for justification."""
2329

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""
2+
Forkchoice algorithm implementation.
3+
4+
This module implements the LMD GHOST forkchoice algorithm for Ethereum,
5+
providing the core functionality for determining the canonical chain head.
6+
"""
7+
8+
from .helpers import (
9+
get_fork_choice_head,
10+
get_latest_justified,
11+
)
12+
from .store import Store
13+
14+
__all__ = [
15+
"Store",
16+
"get_fork_choice_head",
17+
"get_latest_justified",
18+
]
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Forkchoice algorithm constants."""
2+
3+
from lean_spec.types import Bytes32
4+
5+
ZERO_HASH = Bytes32.zero()
6+
"""All-zero hash used as genesis parent."""
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""
2+
Core forkchoice algorithms.
3+
4+
Pure functions implementing the LMD GHOST forkchoice rule and related utilities.
5+
"""
6+
7+
from typing import Dict, Optional
8+
9+
from lean_spec.subspecs.containers import Block, Checkpoint, State
10+
from lean_spec.types import Bytes32, ValidatorIndex
11+
12+
from .constants import ZERO_HASH
13+
14+
15+
def get_fork_choice_head(
16+
blocks: Dict[Bytes32, Block],
17+
root: Bytes32,
18+
latest_votes: Dict[ValidatorIndex, Checkpoint],
19+
min_score: int = 0,
20+
) -> Bytes32:
21+
"""
22+
Use LMD GHOST to find the head block from a given root.
23+
24+
Args:
25+
blocks: All known blocks indexed by hash.
26+
root: Starting point root (usually latest justified).
27+
latest_votes: Current votes by validator index.
28+
min_score: Minimum vote count for block inclusion.
29+
30+
Returns:
31+
Hash of the chosen head block.
32+
"""
33+
# Start at genesis if root is zero hash
34+
if root == ZERO_HASH:
35+
root = min(blocks.keys(), key=lambda block_hash: blocks[block_hash].slot)
36+
37+
# If no votes, return the starting root immediately
38+
if not latest_votes:
39+
return root
40+
41+
# Count votes for each block (votes for descendants count for ancestors)
42+
vote_weights: Dict[Bytes32, int] = {}
43+
44+
for vote in latest_votes.values():
45+
if vote.root in blocks:
46+
# Walk up from vote target, incrementing ancestor weights
47+
block_hash = vote.root
48+
while blocks[block_hash].slot > blocks[root].slot:
49+
vote_weights[block_hash] = vote_weights.get(block_hash, 0) + 1
50+
block_hash = blocks[block_hash].parent_root
51+
52+
# Build children mapping for blocks above min score
53+
children_map: Dict[Bytes32, list[Bytes32]] = {}
54+
for block_hash, block in blocks.items():
55+
if block.parent_root and vote_weights.get(block_hash, 0) >= min_score:
56+
children_map.setdefault(block.parent_root, []).append(block_hash)
57+
58+
# Walk down tree, choosing child with most votes (tiebreak by slot, then hash)
59+
current = root
60+
while True:
61+
children = children_map.get(current, [])
62+
if not children:
63+
return current
64+
65+
# Choose best child: most votes, then highest slot, then highest hash
66+
current = max(children, key=lambda x: (vote_weights.get(x, 0), blocks[x].slot, x))
67+
68+
69+
def get_latest_justified(states: Dict[Bytes32, "State"]) -> Optional[Checkpoint]:
70+
"""
71+
Find the justified checkpoint with the highest slot.
72+
73+
Args:
74+
states: All known states indexed by hash.
75+
76+
Returns:
77+
Latest justified checkpoint, or None if no states.
78+
"""
79+
if not states:
80+
return None
81+
82+
# Find state with maximum justified slot
83+
latest_state = max(states.values(), key=lambda s: s.latest_justified.slot)
84+
85+
# Return latest justified checkpoint from that state
86+
return latest_state.latest_justified

0 commit comments

Comments
 (0)