Skip to content

Commit 0bc0f7f

Browse files
committed
refactor: some fork choice logic to help advance chain with blocks
- forkchoice was not advancing chain with blocks, made some minor changes as compared to consensus-specs that may not be correct but help show how to write forkchoice tests for vector generation.
1 parent 3964afa commit 0bc0f7f

File tree

12 files changed

+91
-59
lines changed

12 files changed

+91
-59
lines changed

packages/tests/src/lean_spec_tests/base_types.py

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,3 @@ class CamelModel(BaseModel):
2525
def copy(self: Self, **kwargs: Any) -> Self:
2626
"""Create a copy of the model with the updated fields that are validated."""
2727
return self.__class__(**(self.model_dump(exclude_unset=True) | kwargs))
28-
29-
@staticmethod
30-
def json_encoder(obj: Any) -> Any:
31-
"""
32-
Custom JSON encoder for leanSpec types.
33-
34-
Converts:
35-
- Bytes32 and other BaseBytes types → "0x..." hex string
36-
- Uint64 and other BaseUint types → int
37-
"""
38-
# Check if it's a bytes type (has hex() method)
39-
if hasattr(obj, "hex") and callable(obj.hex):
40-
return f"0x{obj.hex()}"
41-
# Check if it's a uint type (subclass of int but not bool)
42-
if isinstance(obj, int) and not isinstance(obj, bool):
43-
return int(obj)
44-
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")

packages/tests/src/lean_spec_tests/pytest_plugins/filler.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
from lean_spec.subspecs.containers import State
1010
from lean_spec.types import Uint64
11-
from lean_spec_tests.base_types import CamelModel
1211
from lean_spec_tests.forks import Fork, get_fork_by_name, get_forks
1312
from lean_spec_tests.test_fixtures import BaseConsensusFixture
1413

@@ -127,7 +126,6 @@ def write_fixtures(self) -> None:
127126
all_tests,
128127
f,
129128
indent=4,
130-
default=CamelModel.json_encoder,
131129
)
132130

133131

packages/tests/src/lean_spec_tests/test_fixtures/base.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import hashlib
44
import json
5+
from functools import cached_property
56
from typing import Any, ClassVar, Dict, Type
67

78
from pydantic import Field
@@ -51,7 +52,7 @@ def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
5152
if cls.format_name:
5253
BaseConsensusFixture.formats[cls.format_name] = cls
5354

54-
@property
55+
@cached_property
5556
def json_dict(self) -> Dict[str, Any]:
5657
"""
5758
Return the JSON representation of the fixture.
@@ -65,7 +66,7 @@ def json_dict(self) -> Dict[str, Any]:
6566
exclude={"info"},
6667
)
6768

68-
@property
69+
@cached_property
6970
def hash(self) -> str:
7071
"""
7172
Generate a deterministic hash for this fixture.
@@ -77,7 +78,6 @@ def hash(self) -> str:
7778
self.json_dict,
7879
sort_keys=True,
7980
separators=(",", ":"),
80-
default=CamelModel.json_encoder,
8181
)
8282
h = hashlib.sha256(json_str.encode("utf-8")).hexdigest()
8383
return f"0x{h}"

src/lean_spec/subspecs/forkchoice/helpers.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,6 @@ def get_fork_choice_head(
3434
if root == ZERO_HASH:
3535
root = min(blocks.keys(), key=lambda block_hash: blocks[block_hash].slot)
3636

37-
# If no votes, return the starting root immediately
38-
if not latest_votes:
39-
return root
40-
4137
# Count votes for each block (votes for descendants count for ancestors)
4238
vote_weights: Dict[Bytes32, int] = {}
4339

@@ -49,21 +45,26 @@ def get_fork_choice_head(
4945
vote_weights[block_hash] = vote_weights.get(block_hash, 0) + 1
5046
block_hash = blocks[block_hash].parent_root
5147

52-
# Build children mapping for blocks above min score
48+
# Build children mapping for ALL blocks (not just those above min_score)
49+
# This ensures fork choice works even when there are no votes
5350
children_map: Dict[Bytes32, list[Bytes32]] = {}
5451
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)
52+
if block.parent_root:
53+
# Only include blocks that have enough votes OR when min_score is 0
54+
if min_score == 0 or vote_weights.get(block_hash, 0) >= min_score:
55+
children_map.setdefault(block.parent_root, []).append(block_hash)
5756

58-
# Walk down tree, choosing child with most votes (tiebreak by slot, then hash)
57+
# Walk down tree, choosing child with most votes (tiebreak by lexicographic hash)
58+
# Matches consensus-specs: (weight, root) for tie-breaking
5959
current = root
6060
while True:
6161
children = children_map.get(current, [])
6262
if not children:
6363
return current
6464

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))
65+
# Choose best child: most votes, then lexicographically highest hash
66+
# Note: Removed slot tiebreaker to match consensus-specs
67+
current = max(children, key=lambda x: (vote_weights.get(x, 0), x))
6768

6869

6970
def get_latest_justified(states: Dict[Bytes32, "State"]) -> Optional[Checkpoint]:

src/lean_spec/subspecs/forkchoice/store.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,17 @@ class method acts as a factory for creating a new Store instance.
102102
anchor_root = hash_tree_root(anchor_block)
103103
anchor_slot = anchor_block.slot
104104

105+
# Create checkpoint from anchor block (matches consensus-specs)
106+
# The anchor block becomes the initial justified and finalized checkpoint
107+
anchor_checkpoint = Checkpoint(root=anchor_root, slot=anchor_slot)
108+
105109
return cls(
106110
time=Uint64(anchor_slot * INTERVALS_PER_SLOT),
107111
config=state.config,
108112
head=anchor_root,
109113
safe_target=anchor_root,
110-
latest_justified=state.latest_justified,
111-
latest_finalized=state.latest_finalized,
114+
latest_justified=anchor_checkpoint,
115+
latest_finalized=anchor_checkpoint,
112116
blocks={anchor_root: copy.copy(anchor_block)},
113117
states={anchor_root: copy.copy(state)},
114118
)

tests/lean_spec/subspecs/forkchoice/test_fork_choice_algorithm.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,14 @@ class TestLMDGHOSTAlgorithm:
5959
"""Test the core LMD GHOST fork choice algorithm."""
6060

6161
def test_fork_choice_no_votes(self, sample_blocks: Dict[Bytes32, Block]) -> None:
62-
"""Test fork choice algorithm with no votes returns the root."""
62+
"""
63+
Test fork choice algorithm with no votes walks to the leaf.
64+
65+
With no votes, fork choice should walk down the tree and select the
66+
leaf block (the furthest descendant), breaking ties by lexicographic hash.
67+
"""
6368
root_hash = list(sample_blocks.keys())[0]
69+
leaf_hash = list(sample_blocks.keys())[2] # block_b (slot 2, the leaf)
6470

6571
head = get_fork_choice_head(
6672
blocks=sample_blocks,
@@ -69,7 +75,7 @@ def test_fork_choice_no_votes(self, sample_blocks: Dict[Bytes32, Block]) -> None
6975
min_score=0,
7076
)
7177

72-
assert head == root_hash
78+
assert head == leaf_hash
7379

7480
def test_fork_choice_single_vote(self, sample_blocks: Dict[Bytes32, Block]) -> None:
7581
"""Test fork choice algorithm with a single vote."""
@@ -254,16 +260,17 @@ def test_fork_choice_tie_breaking(self) -> None:
254260
block_b_hash: block_b,
255261
}
256262

257-
# No votes - algorithm returns the starting root (genesis)
263+
# No votes - algorithm breaks tie by lexicographically highest hash
258264
head = get_fork_choice_head(
259265
blocks=blocks,
260266
root=genesis_hash,
261267
latest_votes={},
262268
min_score=0,
263269
)
264270

265-
# Should return the genesis block when no votes exist
266-
assert head == genesis_hash
271+
# Should return the block with lexicographically highest hash
272+
expected_head = max(block_a_hash, block_b_hash)
273+
assert head == expected_head
267274

268275
def test_fork_choice_deep_chain(self) -> None:
269276
"""Test fork choice algorithm with a deeper chain."""

tests/lean_spec/subspecs/forkchoice/test_helpers.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,15 @@ def test_get_fork_choice_head_with_votes(self, sample_blocks: Dict[Bytes32, Bloc
7373
assert head == target_hash
7474

7575
def test_get_fork_choice_head_no_votes(self, sample_blocks: Dict[Bytes32, Block]) -> None:
76-
"""Test get_fork_choice_head with no votes returns the root."""
76+
"""Test get_fork_choice_head with no votes walks to the leaf."""
7777
root_hash = list(sample_blocks.keys())[0]
78+
leaf_hash = list(sample_blocks.keys())[2] # block_b is the leaf
7879

7980
head = get_fork_choice_head(
8081
blocks=sample_blocks, root=root_hash, latest_votes={}, min_score=0
8182
)
8283

83-
assert head == root_hash
84+
assert head == leaf_hash
8485

8586
def test_get_fork_choice_head_with_min_score(self, sample_blocks: Dict[Bytes32, Block]) -> None:
8687
"""Test get_fork_choice_head respects minimum score."""

tests/lean_spec/subspecs/forkchoice/test_store_lifecycle.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,13 @@ def test_store_factory_method(self) -> None:
139139

140140
# Verify initialization
141141
anchor_root = hash_tree_root(anchor_block)
142+
anchor_checkpoint = Checkpoint(root=anchor_root, slot=Slot(0))
142143
assert store.config == state.config
143144
assert store.head == anchor_root
144145
assert store.safe_target == anchor_root
145-
assert store.latest_justified == state.latest_justified
146-
assert store.latest_finalized == state.latest_finalized
146+
# Store uses anchor checkpoint, not state's checkpoint
147+
assert store.latest_justified == anchor_checkpoint
148+
assert store.latest_finalized == anchor_checkpoint
147149
assert anchor_root in store.blocks
148150
assert anchor_root in store.states
149151

tests/lean_spec/subspecs/forkchoice/test_validator.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,8 @@ def test_multiple_validators_coordination(self, sample_store: Store) -> None:
371371
assert att.source.root == first_att.source.root
372372

373373
# Validator 2 produces next block for slot 2
374-
# Without votes for block1, this will build on genesis (current head)
374+
# After processing block1, head should be block1 (fork choice walks the tree)
375+
# So block2 will build on block1
375376
block2 = sample_store.produce_block(Slot(2), ValidatorIndex(2))
376377

377378
# Verify block properties
@@ -383,10 +384,18 @@ def test_multiple_validators_coordination(self, sample_store: Store) -> None:
383384
assert block1_hash in sample_store.blocks
384385
assert block2_hash in sample_store.blocks
385386

386-
# Both blocks should build on genesis (the current head)
387-
genesis_hash = sample_store.head
387+
# block1 builds on genesis, block2 builds on block1 (current head)
388+
# Get the original genesis hash from the store's blocks
389+
genesis_hash = min(
390+
(
391+
root
392+
for root in sample_store.blocks.keys()
393+
if sample_store.blocks[root].slot == Slot(0)
394+
),
395+
key=lambda root: root,
396+
)
388397
assert block1.parent_root == genesis_hash
389-
assert block2.parent_root == genesis_hash
398+
assert block2.parent_root == block1_hash
390399

391400
def test_validator_edge_cases(self, sample_store: Store) -> None:
392401
"""Test edge cases in validator operations."""

tests/spec_tests/devnet/fork_choice/test_head_selection.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,54 +5,81 @@
55
BlockBuilder,
66
BlockStep,
77
ForkChoiceTestFiller,
8+
StoreChecks,
89
)
910

1011
from lean_spec.subspecs.containers.slot import Slot
1112
from lean_spec.subspecs.containers.state import State
13+
from lean_spec.subspecs.ssz.hash import hash_tree_root
1214

1315
pytestmark = pytest.mark.valid_until("Devnet")
1416

1517

16-
def test_process_single_block(
18+
def test_head_updates_after_single_block(
1719
fork_choice_test: ForkChoiceTestFiller,
1820
genesis: State,
1921
) -> None:
2022
"""
21-
Test that a single block can be processed successfully.
23+
Test that head updates correctly after processing a single block.
2224
23-
This is the simplest fork choice test - just verify that we can
24-
process a block through the Store without errors.
25+
With no attestations, fork choice should select the latest block
26+
on the canonical chain.
2527
"""
2628
# Create a block at slot 1
2729
block1 = BlockBuilder(genesis).build(Slot(1))
30+
block1_root = hash_tree_root(block1.message)
2831

2932
# Generate the test fixture
30-
# Note: anchor_block is auto-generated from anchor_state
31-
# We don't add checks here - just verify the block processes successfully
33+
# After processing block 1, the head should point to block 1
3234
fork_choice_test(
3335
anchor_state=genesis,
3436
steps=[
35-
BlockStep(block=block1),
37+
BlockStep(
38+
block=block1,
39+
checks=StoreChecks(
40+
head_slot=Slot(1),
41+
head_root=block1_root,
42+
),
43+
),
3644
],
3745
)
3846

3947

40-
def test_process_two_blocks_sequential(
48+
def test_head_advances_with_sequential_blocks(
4149
fork_choice_test: ForkChoiceTestFiller,
4250
genesis: State,
4351
) -> None:
44-
"""Test processing two sequential blocks."""
52+
"""
53+
Test head selection advances through sequential blocks.
54+
55+
Each new block should become the new head since there are no forks.
56+
"""
4557
# Create blocks
4658
block1 = BlockBuilder(genesis).build(Slot(1))
59+
block1_root = hash_tree_root(block1.message)
60+
4761
state_after_block1 = genesis.state_transition(block1)
4862
block2 = BlockBuilder(state_after_block1).build(Slot(2))
63+
block2_root = hash_tree_root(block2.message)
4964

5065
# Generate the test fixture
51-
# Just verify both blocks process successfully
66+
# Head should advance from block 1 to block 2
5267
fork_choice_test(
5368
anchor_state=genesis,
5469
steps=[
55-
BlockStep(block=block1),
56-
BlockStep(block=block2),
70+
BlockStep(
71+
block=block1,
72+
checks=StoreChecks(
73+
head_slot=Slot(1),
74+
head_root=block1_root,
75+
),
76+
),
77+
BlockStep(
78+
block=block2,
79+
checks=StoreChecks(
80+
head_slot=Slot(2),
81+
head_root=block2_root,
82+
),
83+
),
5784
],
5885
)

0 commit comments

Comments
 (0)