Skip to content

Commit a6e45eb

Browse files
committed
fix(testing): make equivocation head assertions scheme-independent
The equivocation tiebreak vectors hardcoded the winning fork of #1181's largest-attestation-data-root rule. That root embeds validator XMSS pubkeys, so the winner flips between test and prod schemes; the vectors passed PR CI (test scheme) and failed prod-vectors on main (prod scheme). Same class as #916. Add a scheme-independent canonical_equivocation_head_among check that reads each fork's attestation root from the store and asserts the head is the largest-root fork, mirroring lexicographic_head_among. Rewrite the 3 vectors to use it, note the scheme-dependence on the spec tiebreak, add a CLAUDE.md rule, and gate fork-choice vectors under --scheme=prod in PR CI.
1 parent 8b4ebbe commit a6e45eb

5 files changed

Lines changed: 184 additions & 30 deletions

File tree

.github/workflows/ci.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,41 @@ jobs:
159159
- name: Fill test fixtures
160160
run: just fill-ci
161161

162+
# Scheme-fragility guard. A root-based fork-choice tiebreak embeds the validator XMSS public
163+
# keys, so a vector that hardcodes the winning fork can pass under the default `test` scheme
164+
# and fail under `prod`. This reruns the fork-choice vectors under the prod scheme to catch
165+
# that pre-merge. See CLAUDE.md, "FORK-CHOICE HEAD ASSERTIONS MUST BE SCHEME-INDEPENDENT".
166+
# Determinism is already gated by fill-tests; this job disables it to stay a fast smoke check.
167+
fill-tests-prod-scheme:
168+
name: Fill fork-choice fixtures under the prod scheme - Python 3.14
169+
runs-on: macos-latest
170+
timeout-minutes: 20
171+
steps:
172+
- name: Checkout leanSpec
173+
uses: actions/checkout@v4
174+
175+
- name: Set up Python 3.14
176+
uses: actions/setup-python@v5
177+
with:
178+
python-version: "3.14"
179+
180+
- name: Install uv
181+
uses: astral-sh/setup-uv@v4
182+
with:
183+
enable-cache: true
184+
cache-dependency-glob: "pyproject.toml"
185+
186+
- name: Install just
187+
uses: taiki-e/install-action@v2
188+
with:
189+
tool: just
190+
191+
- name: Download prod scheme keys
192+
run: uv run keys --download --scheme prod
193+
194+
- name: Fill fork-choice fixtures under the prod scheme
195+
run: just fill-ci --scheme=prod --no-check-determinism tests/consensus/lstar/fork_choice
196+
162197
interop-tests:
163198
name: Interop tests - Multi-node consensus
164199
runs-on: macos-latest

CLAUDE.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,21 @@ subspecifications that the Lean Ethereum protocol relies on.
128128
one-to-one: if an assertion changes, the matching docstring line changes with it.
129129
- Do not weaken a docstring into vagueness to avoid updating it; describe the new behavior
130130
precisely, as the doc-writer rules require.
131+
- **CRITICAL - FORK-CHOICE HEAD ASSERTIONS MUST BE SCHEME-INDEPENDENT**: This is a STRICT
132+
requirement. A fork-choice head assertion (`head_slot`, `head_root_label`, or any check that
133+
pins which block is head) must NOT depend on the signature scheme. Block and attestation-data
134+
roots embed the genesis state root, which embeds the validator XMSS public keys, so a
135+
root-based tiebreak winner can flip between the `test` and `prod` schemes. A vector that
136+
hardcodes the winning fork passes under one scheme and fails under the other; this has shipped
137+
twice (PR #916, PR #1181).
138+
- Never pin the winner of a weight tie or an equal-slot equivocation tie to a hardcoded fork
139+
label or slot. Assert the scheme-independent facts instead.
140+
- For a weight tie between equal-weight forks, use `lexicographic_head_among` (head = highest
141+
block root, computed from the store).
142+
- For an equal-slot equivocation tie, use `canonical_equivocation_head_among` (head = fork
143+
whose attestation carries the largest attestation-data root, computed from the store).
144+
- When a tie is incidental to the scenario, prefer asserting the genuine behavior at a later
145+
step where a single child or unambiguous weight removes the tie, rather than asserting the
146+
head at the tie step.
147+
- The identity of the winning fork may legitimately differ across schemes; what must hold is
148+
that all nodes with identical store contents pick the identical head.

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

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,18 @@ class StoreChecks(SelectiveCheck):
214214
All listed forks must have equal attestation weight, and the head carries the highest root.
215215
"""
216216

217+
canonical_equivocation_head_among: list[str] | None = None
218+
"""Fork labels in an equal-slot equivocation tie, head on the largest attestation-data root.
219+
220+
Each listed fork must be targeted by at least one attestation in the store's accepted
221+
aggregated pool. The fork whose attestation carries the largest canonical hash_tree_root
222+
absorbs each equivocator's single counted vote, so the head must sit at that fork.
223+
224+
The roots are read from the store, so the assertion holds under any signature scheme;
225+
it never pins which fork wins. The winner is a pure function of store contents, exactly as
226+
the fork-choice latest-vote extraction computes it.
227+
"""
228+
217229
reorg_depth: int | None = None
218230
"""Expected count of blocks from the old head back to its common ancestor with the new head."""
219231

@@ -437,6 +449,18 @@ def _canonical_precedence(vote: AttestationData) -> tuple[Slot, Bytes32]:
437449
self.lexicographic_head_among, store, block_registry, step_index
438450
)
439451

452+
# Equal-slot equivocation tiebreak: head sits on the largest attestation-data root.
453+
if "canonical_equivocation_head_among" in fields:
454+
if block_registry is None:
455+
raise ValueError(
456+
f"Step {step_index}: canonical_equivocation_head_among specified "
457+
f"but block_registry not provided"
458+
)
459+
assert self.canonical_equivocation_head_among is not None
460+
StoreChecks._validate_canonical_equivocation_head(
461+
self.canonical_equivocation_head_among, store, block_registry, step_index
462+
)
463+
440464
# Reorg depth
441465
if "reorg_depth" in fields:
442466
if old_head is None:
@@ -577,3 +601,81 @@ def _validate_lexicographic_head(
577601
f"When forks have equal weight, the fork with the lexicographically "
578602
f"highest root should be selected as head."
579603
)
604+
605+
@staticmethod
606+
def _validate_canonical_equivocation_head(
607+
fork_labels: list[str],
608+
store: Store,
609+
block_registry: dict[str, Block],
610+
step_index: int,
611+
) -> None:
612+
"""
613+
Validate the equal-slot equivocation tiebreak.
614+
615+
Each listed fork must be targeted by at least one attestation in the store's accepted
616+
aggregated pool. The fork whose attestation carries the largest canonical
617+
``hash_tree_root`` absorbs each equivocator's single counted vote, so the head must sit
618+
at that fork. The comparison key is identical to the one the fork-choice latest-vote
619+
extraction sorts on, so the winner derived here is the fork that wins in the spec.
620+
621+
The roots are read from the store rather than hardcoded, which keeps the assertion
622+
independent of the signature scheme: a block root embeds the genesis state root, which
623+
embeds the validator XMSS public keys, so the identity of the winning fork can flip
624+
between schemes. What is invariant is that the head tracks the largest root present.
625+
"""
626+
if len(fork_labels) < 2:
627+
raise ValueError(
628+
f"Step {step_index}: canonical_equivocation_head_among requires at least 2 forks "
629+
f"to test the equivocation tiebreak, got {len(fork_labels)}: {fork_labels}"
630+
)
631+
632+
# Resolve each fork label to its block root.
633+
fork_roots: dict[str, Bytes32] = {}
634+
for label in fork_labels:
635+
if label not in block_registry:
636+
raise ValueError(
637+
f"Step {step_index}: canonical_equivocation_head_among label '{label}' "
638+
f"not found in block registry. Available: {list(block_registry.keys())}"
639+
)
640+
fork_roots[label] = hash_tree_root(block_registry[label])
641+
642+
# For each fork, take the largest canonical attestation-data root among the attestations
643+
# targeting it. This is the same key the latest-vote extraction sorts on, so the fork with
644+
# the maximum value here is the one each equivocator's weight lands on.
645+
attestation_root_by_label: dict[str, Bytes32] = {}
646+
for label, fork_root in fork_roots.items():
647+
targeting_data_roots = [
648+
hash_tree_root(attestation_data)
649+
for attestation_data in store.latest_known_aggregated_payloads
650+
if attestation_data.target.root == fork_root
651+
]
652+
if not targeting_data_roots:
653+
raise AssertionError(
654+
f"Step {step_index}: canonical_equivocation_head_among fork '{label}' "
655+
f"(block_root=0x{fork_root.hex()}) has no attestation targeting it in the "
656+
f"accepted aggregated pool."
657+
)
658+
attestation_root_by_label[label] = max(targeting_data_roots)
659+
660+
winning_label = max(
661+
attestation_root_by_label, key=lambda label: attestation_root_by_label[label]
662+
)
663+
expected_head_root = fork_roots[winning_label]
664+
665+
if store.head != expected_head_root:
666+
actual_label = next(
667+
(label for label, root in fork_roots.items() if root == store.head),
668+
"unknown",
669+
)
670+
fork_info = "\n".join(
671+
f" {label}: block_root=0x{fork_roots[label].hex()} "
672+
f"attestation_data_root=0x{attestation_root_by_label[label].hex()}"
673+
for label in sorted(fork_roots)
674+
)
675+
raise AssertionError(
676+
f"Step {step_index}: canonical equivocation tiebreak failed.\n"
677+
f"The head must be the fork with the largest attestation-data root.\n"
678+
f"Expected head: '{winning_label}' (0x{expected_head_root.hex()})\n"
679+
f"Actual head: '{actual_label}' (0x{store.head.hex()})\n"
680+
f"Competing forks:\n{fork_info}\n"
681+
)

src/lean_spec/spec/forks/lstar/fork_choice.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,14 @@ def _extract_attestations_from_aggregated_payloads(
682682
An equal-slot tie breaks toward the larger canonical attestation-data root.
683683
The result is therefore independent of arrival or insertion order.
684684
685+
What is guaranteed is agreement: any two nodes with identical store contents map every
686+
validator to the identical vote, and so select the identical head. The identity of the
687+
winning fork is not stable across configurations: an attestation-data root embeds its
688+
target block root, which embeds the genesis state root, which embeds the validator XMSS
689+
public keys, so the larger root (and thus the equivocator's landing fork) can flip between
690+
signature schemes. Tests must assert the head against the largest root present in the
691+
store, never against a hardcoded fork label.
692+
685693
A vote whose head sits at or below the finalized slot carries no fork-choice weight.
686694
Such stale votes are skipped here, so callers pass their pool without pre-filtering.
687695

tests/consensus/lstar/fork_choice/test_equivocation.py

Lines changed: 21 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ def test_same_slot_equivocating_attesters_count_once(
246246
fork_choice_test: ForkChoiceTestFiller,
247247
) -> None:
248248
"""
249-
An equivocating validator is counted once, on the canonical-root fork.
249+
An equivocating validator is counted once, on the canonical attestation-data root fork.
250250
251251
Given
252252
-----
@@ -259,18 +259,18 @@ def test_same_slot_equivocating_attesters_count_once(
259259
- one vote at slot 3 targets fork_b from V0, V1, V3, V4.
260260
- V0 and V1 equivocate by voting on both forks at the same slot.
261261
- the two votes tie on slot, so the larger attestation-data root wins.
262-
- the fork_b vote carries the larger root.
263262
264263
When
265264
----
266265
- both votes arrive by gossip and are accepted.
267266
268267
Then
269268
----
270-
- V0 and V1 count once, toward fork_b.
271-
- fork_b has effective weight 4 from V0, V1, V3, V4.
272-
- fork_a has effective weight 1 from V2.
273-
- head is fork_b.
269+
- V0 and V1 each have one canonical vote at slot 3 (counted once), not pinned to a fork
270+
because the winner depends on roots read from the store, not on a hardcoded outcome.
271+
- V2 targets fork_a; V3 and V4 target fork_b (the exclusive, non-equivocating supporters).
272+
- the head is the fork whose vote carries the larger attestation-data root, derived from the
273+
store, so the vector holds under either signature scheme.
274274
- no slot is justified by the below-threshold votes.
275275
"""
276276
fork_choice_test(
@@ -317,8 +317,7 @@ def test_same_slot_equivocating_attesters_count_once(
317317
TickStep(
318318
time=16,
319319
checks=StoreChecks(
320-
head_slot=Slot(3),
321-
head_root_label="fork_b",
320+
canonical_equivocation_head_among=["fork_a", "fork_b"],
322321
latest_justified_slot=Slot(0),
323322
latest_finalized_slot=Slot(0),
324323
latest_known_aggregated_target_slots=[Slot(2), Slot(3)],
@@ -327,13 +326,11 @@ def test_same_slot_equivocating_attesters_count_once(
327326
validator=ValidatorIndex(0),
328327
location="known",
329328
attestation_slot=Slot(3),
330-
target_slot=Slot(3),
331329
),
332330
AttestationCheck(
333331
validator=ValidatorIndex(1),
334332
location="known",
335333
attestation_slot=Slot(3),
336-
target_slot=Slot(3),
337334
),
338335
AttestationCheck(
339336
validator=ValidatorIndex(2),
@@ -364,7 +361,7 @@ def test_equivocation_head_independent_of_arrival_order_a_then_b(
364361
fork_choice_test: ForkChoiceTestFiller,
365362
) -> None:
366363
"""
367-
Head ignores arrival order: fork_a arriving first still picks the canonical-root fork.
364+
Head ignores arrival order: the canonical-root fork wins regardless of vote order.
368365
369366
Given
370367
-----
@@ -377,20 +374,19 @@ def test_equivocation_head_independent_of_arrival_order_a_then_b(
377374
- one vote at slot 3 targets fork_b from V0, V2.
378375
- V0 equivocates by voting on both forks at the same slot.
379376
- V1 backs fork_a alone; V2 backs fork_b alone.
380-
- without V0 the two forks tie one-to-one, so V0's vote decides the head.
377+
- without V0 the two forks tie one-to-one, so V0's single counted vote decides the head.
381378
- the two votes tie on slot, so the larger attestation-data root wins.
382-
- the fork_a vote carries the larger root.
383379
384380
When
385381
----
386382
- the fork_a vote arrives first, then the fork_b vote.
387383
388384
Then
389385
----
390-
- V0 counts once, toward the larger-root fork.
391-
- the larger-root fork has effective weight 2.
392-
- the smaller-root fork has effective weight 1.
393-
- head is fork_a.
386+
- V0 counts once, toward whichever fork's vote carries the larger attestation-data root.
387+
- that fork has effective weight 2; the other has weight 1.
388+
- the head is that fork, derived from the store roots rather than pinned, so it matches the
389+
opposite arrival order under either signature scheme.
394390
- no slot is justified by the below-threshold votes.
395391
"""
396392
fork_choice_test(
@@ -428,16 +424,14 @@ def test_equivocation_head_independent_of_arrival_order_a_then_b(
428424
TickStep(
429425
time=16,
430426
checks=StoreChecks(
431-
head_slot=Slot(2),
432-
head_root_label="fork_a",
427+
canonical_equivocation_head_among=["fork_a", "fork_b"],
433428
latest_justified_slot=Slot(0),
434429
latest_finalized_slot=Slot(0),
435430
attestation_checks=[
436431
AttestationCheck(
437432
validator=ValidatorIndex(0),
438433
location="known",
439434
attestation_slot=Slot(3),
440-
target_slot=Slot(2),
441435
),
442436
],
443437
),
@@ -450,7 +444,7 @@ def test_equivocation_head_independent_of_arrival_order_b_then_a(
450444
fork_choice_test: ForkChoiceTestFiller,
451445
) -> None:
452446
"""
453-
Head ignores arrival order: fork_b arriving first still picks the canonical-root fork.
447+
Head ignores arrival order: the canonical-root fork wins regardless of vote order.
454448
455449
Given
456450
-----
@@ -463,20 +457,19 @@ def test_equivocation_head_independent_of_arrival_order_b_then_a(
463457
- one vote at slot 3 targets fork_b from V0, V2.
464458
- V0 equivocates by voting on both forks at the same slot.
465459
- V1 backs fork_a alone; V2 backs fork_b alone.
466-
- without V0 the two forks tie one-to-one, so V0's vote decides the head.
460+
- without V0 the two forks tie one-to-one, so V0's single counted vote decides the head.
467461
- the two votes tie on slot, so the larger attestation-data root wins.
468-
- the fork_a vote carries the larger root.
469462
470463
When
471464
----
472465
- the fork_b vote arrives first, then the fork_a vote.
473466
474467
Then
475468
----
476-
- V0 counts once, toward the larger-root fork.
477-
- the larger-root fork has effective weight 2.
478-
- the smaller-root fork has effective weight 1.
479-
- head is fork_a, matching the opposite arrival order.
469+
- V0 counts once, toward whichever fork's vote carries the larger attestation-data root.
470+
- that fork has effective weight 2; the other has weight 1.
471+
- the head is that fork, derived from the store roots rather than pinned, so it matches the
472+
opposite arrival order under either signature scheme.
480473
- no slot is justified by the below-threshold votes.
481474
"""
482475
fork_choice_test(
@@ -514,16 +507,14 @@ def test_equivocation_head_independent_of_arrival_order_b_then_a(
514507
TickStep(
515508
time=16,
516509
checks=StoreChecks(
517-
head_slot=Slot(2),
518-
head_root_label="fork_a",
510+
canonical_equivocation_head_among=["fork_a", "fork_b"],
519511
latest_justified_slot=Slot(0),
520512
latest_finalized_slot=Slot(0),
521513
attestation_checks=[
522514
AttestationCheck(
523515
validator=ValidatorIndex(0),
524516
location="known",
525517
attestation_slot=Slot(3),
526-
target_slot=Slot(2),
527518
),
528519
],
529520
),

0 commit comments

Comments
 (0)