Skip to content

Commit eca701e

Browse files
authored
fix(testing): make equivocation head assertions scheme-independent (#1189)
* 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. * ci: drop prod-scheme fork-choice smoke job The fill-tests-prod-scheme job was cancelled at its 20-minute timeout on the macOS runner (the fork-choice tree under --scheme=prod takes >20 min in CI). Remove it; the scheme-independence rule in CLAUDE.md and the canonical_equivocation_head_among check remain. * docs: trim equivocation scheme-independence notes Cut the root-to-pubkey derivations and test guidance an experienced reader already knows. The CLAUDE.md rule keeps the actionable bullets, the check's field docstring matches lexicographic_head_among's brevity, and the spec docstring states only the behavioral property (no test instructions).
1 parent 8b4ebbe commit eca701e

4 files changed

Lines changed: 116 additions & 30 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,11 @@ 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**: A head assertion that
132+
pins the winner of a root-based tiebreak (`head_slot`, `head_root_label`) is scheme-fragile —
133+
roots embed validator keys, so the winner can flip between `test` and `prod` (shipped in #916,
134+
#1181). Assert the invariant, not the winner.
135+
- Equal-weight tie → `lexicographic_head_among` (highest block root, from the store).
136+
- Equal-slot equivocation tie → `canonical_equivocation_head_among` (largest attestation-data
137+
root, from the store).
138+
- If the tie is incidental, assert the head at a later step where it is unambiguous.

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,14 @@ 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 an attestation in the accepted pool; the head must be
221+
the fork whose attestation carries the largest hash_tree_root. Scheme-independent (roots are
222+
read from the store).
223+
"""
224+
217225
reorg_depth: int | None = None
218226
"""Expected count of blocks from the old head back to its common ancestor with the new head."""
219227

@@ -437,6 +445,18 @@ def _canonical_precedence(vote: AttestationData) -> tuple[Slot, Bytes32]:
437445
self.lexicographic_head_among, store, block_registry, step_index
438446
)
439447

448+
# Equal-slot equivocation tiebreak: head sits on the largest attestation-data root.
449+
if "canonical_equivocation_head_among" in fields:
450+
if block_registry is None:
451+
raise ValueError(
452+
f"Step {step_index}: canonical_equivocation_head_among specified "
453+
f"but block_registry not provided"
454+
)
455+
assert self.canonical_equivocation_head_among is not None
456+
StoreChecks._validate_canonical_equivocation_head(
457+
self.canonical_equivocation_head_among, store, block_registry, step_index
458+
)
459+
440460
# Reorg depth
441461
if "reorg_depth" in fields:
442462
if old_head is None:
@@ -577,3 +597,66 @@ def _validate_lexicographic_head(
577597
f"When forks have equal weight, the fork with the lexicographically "
578598
f"highest root should be selected as head."
579599
)
600+
601+
@staticmethod
602+
def _validate_canonical_equivocation_head(
603+
fork_labels: list[str],
604+
store: Store,
605+
block_registry: dict[str, Block],
606+
step_index: int,
607+
) -> None:
608+
"""Validate the equal-slot equivocation tiebreak."""
609+
if len(fork_labels) < 2:
610+
raise ValueError(
611+
f"Step {step_index}: canonical_equivocation_head_among requires at least 2 forks "
612+
f"to test the equivocation tiebreak, got {len(fork_labels)}: {fork_labels}"
613+
)
614+
615+
# Resolve each fork label to its block root.
616+
fork_roots: dict[str, Bytes32] = {}
617+
for label in fork_labels:
618+
if label not in block_registry:
619+
raise ValueError(
620+
f"Step {step_index}: canonical_equivocation_head_among label '{label}' "
621+
f"not found in block registry. Available: {list(block_registry.keys())}"
622+
)
623+
fork_roots[label] = hash_tree_root(block_registry[label])
624+
625+
# Largest attestation-data root per fork — the key latest-vote extraction sorts on.
626+
attestation_root_by_label: dict[str, Bytes32] = {}
627+
for label, fork_root in fork_roots.items():
628+
targeting_data_roots = [
629+
hash_tree_root(attestation_data)
630+
for attestation_data in store.latest_known_aggregated_payloads
631+
if attestation_data.target.root == fork_root
632+
]
633+
if not targeting_data_roots:
634+
raise AssertionError(
635+
f"Step {step_index}: canonical_equivocation_head_among fork '{label}' "
636+
f"(block_root=0x{fork_root.hex()}) has no attestation targeting it in the "
637+
f"accepted aggregated pool."
638+
)
639+
attestation_root_by_label[label] = max(targeting_data_roots)
640+
641+
winning_label = max(
642+
attestation_root_by_label, key=lambda label: attestation_root_by_label[label]
643+
)
644+
expected_head_root = fork_roots[winning_label]
645+
646+
if store.head != expected_head_root:
647+
actual_label = next(
648+
(label for label, root in fork_roots.items() if root == store.head),
649+
"unknown",
650+
)
651+
fork_info = "\n".join(
652+
f" {label}: block_root=0x{fork_roots[label].hex()} "
653+
f"attestation_data_root=0x{attestation_root_by_label[label].hex()}"
654+
for label in sorted(fork_roots)
655+
)
656+
raise AssertionError(
657+
f"Step {step_index}: canonical equivocation tiebreak failed.\n"
658+
f"The head must be the fork with the largest attestation-data root.\n"
659+
f"Expected head: '{winning_label}' (0x{expected_head_root.hex()})\n"
660+
f"Actual head: '{actual_label}' (0x{store.head.hex()})\n"
661+
f"Competing forks:\n{fork_info}\n"
662+
)

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,10 @@ 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+
Agreement is guaranteed — nodes with identical store contents select the identical head —
686+
but the winning fork's identity is not stable across signature schemes (roots embed
687+
validator keys).
688+
685689
A vote whose head sits at or below the finalized slot carries no fork-choice weight.
686690
Such stale votes are skipped here, so callers pass their pool without pre-filtering.
687691

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)