Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,12 @@ def _resolve_label(field_name: str, label: str) -> Bytes32:
"latest_justified_root_label", self.latest_justified_root_label
)
_check("latest_justified.root", store.latest_justified.root, expected)
if "latest_finalized_root_label" in fields:
assert self.latest_finalized_root_label is not None
expected = _resolve_label(
"latest_finalized_root_label", self.latest_finalized_root_label
)
_check("latest_finalized.root", store.latest_finalized.root, expected)

# Attestation target checkpoint (slot + root consistency)
if "attestation_target_slot" in fields:
Expand Down
219 changes: 219 additions & 0 deletions tests/consensus/devnet/fc/test_fork_choice_head.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,3 +551,222 @@ def test_head_selection_by_weight_not_depth(
),
],
)


def test_fork_from_before_finalization_not_considered(
fork_choice_test: ForkChoiceTestFiller,
) -> None:
"""
A fork with majority weight is ignored when it originates before finalization.

Scenario
--------
Eight validators. Canonical chain achieves finalization at slot 3.
A dead fork branches from block_2 (before finalization) and carries
attestations from 5/8 validators -- more weight than canonical::

genesis -> block_1(1) -> block_2(2) -> block_3(3) -> block_4(4) -> block_5(5)
\\
+-> dead_6(6) -> dead_7(7)
[V3-V7 attest here: weight 5]

Canonical: V0-V5 (6/8) attest in each block -> justification chain.
Dead fork: V3-V7 (5/8) attest in dead_7 targeting dead_6.

- 5 attesters stay below 2/3 threshold (3*5=15 < 2*8=16),
so no justification is triggered on the dead fork
- V3-V5's dead fork attestations (slot 9) override their canonical
ones (slot 5), giving the dead fork MORE total weight than canonical

Expected post-state
-------------------
- Head = block_5 (despite dead fork having weight 5 vs canonical 3)
- Justified = slot 4 (unchanged, dead fork did not trigger justification)
- Finalized = slot 3

Why the dead fork loses
-----------------------
LMD-GHOST starts from the justified root (block_4, slot 4).
The forward walk visits block_4 -> block_5 -> done.
The dead fork branches from block_2, which is an ancestor of
block_4, not a descendant. It is never reached in the walk.
"""
fork_choice_test(
anchor_state=generate_pre_state(num_validators=8),
steps=[
# Canonical justification / finalization chain
# =============================================
#
# genesis -> 1 -> 2 -> 3 -> 4 -> 5
#
# V0-V5 (6/8) attest in each block.
# Threshold: 3*6=18 >= 2*8=16 -> supermajority each time.
#
# After block 2: justified=1, finalized=0
# After block 3: justified=2, finalized=1
# After block 4: justified=3, finalized=2
# After block 5: justified=4, finalized=3
BlockStep(
block=BlockSpec(slot=Slot(1), label="block_1"),
checks=StoreChecks(head_slot=Slot(1)),
),
# Justify slot 1: 6/8 validators attest.
BlockStep(
block=BlockSpec(
slot=Slot(2),
label="block_2",
parent_label="block_1",
attestations=[
AggregatedAttestationSpec(
validator_ids=[ValidatorIndex(i) for i in range(6)],
slot=Slot(2),
target_slot=Slot(1),
target_root_label="block_1",
),
],
),
checks=StoreChecks(
head_slot=Slot(2),
latest_justified_slot=Slot(1),
latest_finalized_slot=Slot(0),
),
),
# Justify slot 2, finalize slot 1.
# Finalization: range(1+1, 2) is empty -> no gap -> finalizes source.
BlockStep(
block=BlockSpec(
slot=Slot(3),
label="block_3",
parent_label="block_2",
attestations=[
AggregatedAttestationSpec(
validator_ids=[ValidatorIndex(i) for i in range(6)],
slot=Slot(3),
target_slot=Slot(2),
target_root_label="block_2",
),
],
),
checks=StoreChecks(
head_slot=Slot(3),
latest_justified_slot=Slot(2),
latest_finalized_slot=Slot(1),
),
),
# Justify slot 3, finalize slot 2.
BlockStep(
block=BlockSpec(
slot=Slot(4),
label="block_4",
parent_label="block_3",
attestations=[
AggregatedAttestationSpec(
validator_ids=[ValidatorIndex(i) for i in range(6)],
slot=Slot(4),
target_slot=Slot(3),
target_root_label="block_3",
),
],
),
checks=StoreChecks(
head_slot=Slot(4),
latest_justified_slot=Slot(3),
latest_finalized_slot=Slot(2),
),
),
# Justify slot 4, finalize slot 3.
BlockStep(
block=BlockSpec(
slot=Slot(5),
label="block_5",
parent_label="block_4",
attestations=[
AggregatedAttestationSpec(
validator_ids=[ValidatorIndex(i) for i in range(6)],
slot=Slot(5),
target_slot=Slot(4),
target_root_label="block_4",
),
],
),
checks=StoreChecks(
head_slot=Slot(5),
head_root_label="block_5",
latest_justified_slot=Slot(4),
latest_finalized_slot=Slot(3),
),
),
# Dead fork with majority weight
# ================================
#
# block_2(2) -> dead_6(6) -> dead_7(7)
#
# Two blocks branching from block_2 (slot 2, before finalized slot 3).
# dead_7 carries attestations from V3-V7 (5/8) targeting dead_6.
#
# Why 5 attesters, not 6?
# Threshold: 3*5=15 < 2*8=16 -> below supermajority.
# No justification on the dead fork. If we used 6/8 attesters,
# the store's justified would advance to dead_6 (slot 6 > slot 4),
# and LMD-GHOST would start from dead_6 -- defeating the test.
#
# Slot justifiability:
# - Slot 6: delta=6 from finalized=0 (dead fork state), pronic (2*3). Valid.
#
# Weight after dead_7 is processed:
#
# Validator | Latest head | Slot | Source
# V0 | block_4 | 5 | canonical
# V1 | block_4 | 5 | canonical
# V2 | block_4 | 5 | canonical
# V3 | dead_6 | 7 | dead fork (overrides canonical slot 5)
# V4 | dead_6 | 7 | dead fork (overrides canonical slot 5)
# V5 | dead_6 | 7 | dead fork (overrides canonical slot 5)
# V6 | dead_6 | 7 | dead fork (never attested on canonical)
# V7 | dead_6 | 7 | dead fork (never attested on canonical)
#
# Dead fork subtree weight: 5 (V3-V7)
# Canonical subtree weight from justified root: 0
# (V0-V2 head=block_4 at slot 4 = start_slot, weight walk stops immediately)
#
# Despite having MORE weight, the dead fork is unreachable.
# LMD-GHOST walks forward from block_4 -> block_5 -> done.
# Dead fork blocks are children of block_2, never visited.
# dead_6: first dead fork block. No attestations yet.
# Head stays on canonical (block_5).
BlockStep(
block=BlockSpec(slot=Slot(6), parent_label="block_2", label="dead_6"),
checks=StoreChecks(
head_slot=Slot(5),
head_root_label="block_5",
),
),
# dead_7: carries 5/8 attestations targeting dead_6.
# V3-V7 now have their latest head on the dead fork.
# Dead fork has weight 5 vs canonical's 0 from the justified root.
# Yet the head does NOT move -- finalization constrains fork choice.
BlockStep(
block=BlockSpec(
slot=Slot(7),
parent_label="dead_6",
label="dead_7",
attestations=[
AggregatedAttestationSpec(
validator_ids=[ValidatorIndex(i) for i in range(3, 8)],
slot=Slot(7),
target_slot=Slot(6),
target_root_label="dead_6",
),
],
),
checks=StoreChecks(
head_slot=Slot(5),
head_root_label="block_5",
# Invariant: justified did NOT advance to dead fork.
# 5/8 attesters: 3*5=15 < 2*8=16 -> below threshold.
latest_justified_slot=Slot(4),
latest_finalized_slot=Slot(3),
),
),
],
)
Loading