Skip to content

Commit aea4c2c

Browse files
tcoratgerunnawut
andauthored
tests: add test_fork_choice_head test vectors (leanEthereum#103)
* tests: add test_fork_choice_head test vectors * Update tests/consensus/devnet/fc/test_fork_choice_head.py Co-authored-by: Unnawut Leepaisalsuwanna <921194+unnawut@users.noreply.github.com> * fix comment --------- Co-authored-by: Unnawut Leepaisalsuwanna <921194+unnawut@users.noreply.github.com>
1 parent 5da200c commit aea4c2c

4 files changed

Lines changed: 533 additions & 9 deletions

File tree

packages/testing/src/consensus_testing/test_fixtures/fork_choice.py

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,12 @@ def make_fixture(self) -> "ForkChoiceTest":
121121
anchor_block=self.anchor_block,
122122
)
123123

124+
# Block registry for label-based fork creation
125+
self._block_registry: dict[str, Block] = {}
126+
127+
# Register genesis/anchor block with implicit label
128+
self._block_registry["genesis"] = self.anchor_block
129+
124130
# Process each step
125131
for i, step in enumerate(self.steps):
126132
try:
@@ -130,12 +136,23 @@ def make_fixture(self) -> "ForkChoiceTest":
130136

131137
elif isinstance(step, BlockStep):
132138
# Build SignedBlockWithAttestation from BlockSpec
133-
signed_block = self._build_block_from_spec(step.block, store)
139+
signed_block = self._build_block_from_spec(
140+
step.block, store, self._block_registry
141+
)
134142

135143
# Store the filled Block for serialization
136144
block = signed_block.message.block
137145
step._filled_block = block
138146

147+
# Register block if it has a label
148+
if step.block.label is not None:
149+
if step.block.label in self._block_registry:
150+
raise ValueError(
151+
f"Step {i}: duplicate label '{step.block.label}' - "
152+
f"labels must be unique within a test"
153+
)
154+
self._block_registry[step.block.label] = block
155+
139156
# Automatically advance time to block's slot before processing
140157
# Compute the time corresponding to the block's slot
141158
block_time = store.config.genesis_time + block.slot * Uint64(SECONDS_PER_SLOT)
@@ -155,7 +172,9 @@ def make_fixture(self) -> "ForkChoiceTest":
155172

156173
# Validate checks if provided
157174
if step.checks is not None:
158-
step.checks.validate_against_store(store, step_index=i)
175+
step.checks.validate_against_store(
176+
store, step_index=i, block_registry=self._block_registry
177+
)
159178

160179
except Exception as e:
161180
if step.valid:
@@ -175,7 +194,12 @@ def make_fixture(self) -> "ForkChoiceTest":
175194
# Return self (fixture is already complete)
176195
return self
177196

178-
def _build_block_from_spec(self, spec: BlockSpec, store: Store) -> SignedBlockWithAttestation:
197+
def _build_block_from_spec(
198+
self,
199+
spec: BlockSpec,
200+
store: Store,
201+
block_registry: dict[str, Block],
202+
) -> SignedBlockWithAttestation:
179203
"""
180204
Build a full SignedBlockWithAttestation from a lightweight BlockSpec.
181205
@@ -194,6 +218,8 @@ def _build_block_from_spec(self, spec: BlockSpec, store: Store) -> SignedBlockWi
194218
The lightweight block specification.
195219
store : Store
196220
The fork choice store (used to get head state and latest justified).
221+
block_registry : dict[str, Block]
222+
Registry of labeled blocks for fork creation.
197223
198224
Returns:
199225
-------
@@ -207,12 +233,31 @@ def _build_block_from_spec(self, spec: BlockSpec, store: Store) -> SignedBlockWi
207233
else:
208234
proposer_index = spec.proposer_index
209235

210-
# Get the current head state from the store
211-
head_state = store.states[store.head]
236+
# Resolve parent block if parent_label is specified
237+
if spec.parent_label is not None:
238+
if spec.parent_label not in block_registry:
239+
raise ValueError(
240+
f"parent_label '{spec.parent_label}' not found - "
241+
f"available labels: {list(block_registry.keys())}"
242+
)
243+
parent_block = block_registry[spec.parent_label]
244+
parent_root = hash_tree_root(parent_block)
245+
246+
# Get state at the parent block
247+
if parent_root not in store.states:
248+
raise ValueError(
249+
f"parent_label '{spec.parent_label}' (root=0x{parent_root.hex()[:16]}...) "
250+
f"has no state in store - cannot build on this fork"
251+
)
252+
parent_state = store.states[parent_root]
212253

213-
# Dry-run to build block with correct state root
214-
temp_state = head_state.process_slots(spec.slot)
215-
parent_root = hash_tree_root(temp_state.latest_block_header)
254+
# Advance state to the new block's slot
255+
temp_state = parent_state.process_slots(spec.slot)
256+
else:
257+
# Default: build on current head
258+
head_state = store.states[store.head]
259+
temp_state = head_state.process_slots(spec.slot)
260+
parent_root = hash_tree_root(temp_state.latest_block_header)
216261

217262
# Build body (empty for now, attestations can be added later if needed)
218263
body = BlockBody(attestations=Attestations(data=[]))

packages/testing/src/consensus_testing/test_types/block_spec.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,31 @@ class BlockSpec(CamelModel):
5252
If None, framework creates empty body for state transition tests,
5353
or collects attestations for fork choice tests.
5454
"""
55+
56+
label: str | None = None
57+
"""
58+
Optional label to tag this block for later reference.
59+
60+
Allows creating forks by building blocks on labeled ancestors:
61+
```python
62+
BlockSpec(slot=Slot(1), label="fork_a") # Tag this block
63+
BlockSpec(slot=Slot(2), parent_label="fork_a") # Build on it
64+
```
65+
66+
Labels must be unique within a test.
67+
"""
68+
69+
parent_label: str | None = None
70+
"""
71+
Optional label referencing a previously created block as parent.
72+
73+
Enables explicit fork creation:
74+
```python
75+
BlockSpec(slot=Slot(1), label="common")
76+
BlockSpec(slot=Slot(2), parent_label="common", label="fork_a")
77+
BlockSpec(slot=Slot(2), parent_label="common", label="fork_b")
78+
```
79+
80+
If None, parent is determined by the current canonical head.
81+
If specified, parent_root is computed from the labeled block.
82+
"""

packages/testing/src/consensus_testing/test_types/store_checks.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
if TYPE_CHECKING:
99
from lean_spec.subspecs.containers import SignedAttestation
10+
from lean_spec.subspecs.containers.block.block import Block
1011
from lean_spec.subspecs.forkchoice.store import Store
1112

1213

@@ -109,6 +110,18 @@ class StoreChecks(CamelModel):
109110
head_root: Bytes32 | None = None
110111
"""Expected head block root."""
111112

113+
head_root_label: str | None = None
114+
"""
115+
Expected head block root by label reference.
116+
117+
Alternative to head_root that uses the block label system.
118+
The framework will resolve this label to the actual block root
119+
and validate the head matches.
120+
121+
Example:
122+
StoreChecks(head_root_label="fork_a") # Validates head is fork_a block
123+
"""
124+
112125
latest_justified_slot: Slot | None = None
113126
"""Expected latest justified checkpoint slot."""
114127

@@ -136,7 +149,9 @@ class StoreChecks(CamelModel):
136149
attestation_checks: list[AttestationCheck] | None = None
137150
"""Optional list of attestation content checks for specific validators."""
138151

139-
def validate_against_store(self, store: "Store", step_index: int) -> None:
152+
def validate_against_store(
153+
self, store: "Store", step_index: int, block_registry: dict[str, "Block"] | None = None
154+
) -> None:
140155
"""
141156
Validate these checks against actual Store state.
142157
@@ -149,6 +164,8 @@ def validate_against_store(self, store: "Store", step_index: int) -> None:
149164
The fork choice store to validate against.
150165
step_index : int
151166
Index of the step being validated (for error messages).
167+
block_registry : dict[str, Block] | None
168+
Optional registry of labeled blocks for resolving head_root_label.
152169
153170
Raises:
154171
------
@@ -183,6 +200,35 @@ def validate_against_store(self, store: "Store", step_index: int) -> None:
183200
f"expected 0x{expected_value.hex()}"
184201
)
185202

203+
elif field_name == "head_root_label":
204+
# Resolve label to root
205+
if block_registry is None:
206+
raise ValueError(
207+
f"Step {step_index}: head_root_label='{expected_value}' specified "
208+
f"but block_registry not provided to validate_against_store()"
209+
)
210+
211+
if expected_value not in block_registry:
212+
available = list(block_registry.keys())
213+
raise ValueError(
214+
f"Step {step_index}: head_root_label='{expected_value}' not found "
215+
f"in block registry. Available labels: {available}"
216+
)
217+
218+
# Import hash_tree_root locally to avoid circular import
219+
from lean_spec.subspecs.ssz import hash_tree_root
220+
221+
expected_block = block_registry[expected_value]
222+
expected_root = hash_tree_root(expected_block)
223+
actual_root = store.head
224+
225+
if actual_root != expected_root:
226+
raise AssertionError(
227+
f"Step {step_index}: head.root = 0x{actual_root.hex()}, "
228+
f"expected 0x{expected_root.hex()} "
229+
f"(label '{expected_value}')"
230+
)
231+
186232
elif field_name == "latest_justified_slot":
187233
actual = store.latest_justified.slot
188234
if actual != expected_value:

0 commit comments

Comments
 (0)