Skip to content

test(fc): head selection by attestation weight, not fork depth #581

@tcoratger

Description

@tcoratger

Context

LMD-GHOST selects the head based on attestation weight, not fork depth. A shorter fork with more attestation support should win over a deeper fork with less support.

This is a fundamental property of LMD-GHOST that distinguishes it from longest-chain rules. Every client must implement this correctly.

The existing fork choice head tests cover competing forks, but they use chain depth (more blocks = more weight implicitly, since each block's proposer attests). This test explicitly separates depth from weight.

What to test

Write a fork choice filler that:

  1. Creates a common prefix at slot 1
  2. Fork A: extends 5 blocks deep (slots 2–6) with 1 attestation each
  3. Fork B: extends only 2 blocks (slots 2–3) but with 4 attestations per block
  4. Verifies LMD-GHOST selects Fork B (heavier), not Fork A (deeper)

Setup with 6 validators

  • Fork A: blocks at slots 2, 3, 4, 5, 6 — each block has 1 validator attesting
  • Fork B: blocks at slots 2, 3 — but validators 0, 1, 2, 3 all attest to slot 3
  • Fork B has more total weight on its head (4 attestations) vs Fork A (1 attestation on its head)

Key assertions

  • `StoreChecks(head_root_label="fork_b_3")` — head is on the shorter but heavier fork
  • After removing attestations from Fork B validators, head could flip to Fork A

Where to add the test

Add to: `tests/consensus/devnet/fc/test_fork_choice_head.py`

Code skeleton

def test_head_selection_by_weight_not_depth(
    fork_choice_test: ForkChoiceTestFiller,
) -> None:
    """Shorter fork with more attestation weight wins over deeper fork."""
    fork_choice_test(
        num_validators=6,
        steps=[
            # Common prefix
            BlockStep(block=BlockSpec(slot=Slot(1), label="common")),
            # Fork A: 5 blocks deep, minimal attestations
            BlockStep(
                block=BlockSpec(slot=Slot(2), parent_label="common", label="a_2"),
            ),
            BlockStep(block=BlockSpec(slot=Slot(3), parent_label="a_2", label="a_3")),
            BlockStep(block=BlockSpec(slot=Slot(4), parent_label="a_3", label="a_4")),
            BlockStep(block=BlockSpec(slot=Slot(5), parent_label="a_4", label="a_5")),
            BlockStep(block=BlockSpec(slot=Slot(6), parent_label="a_5", label="a_6")),
            # Fork B: only 2 blocks, but heavy attestation support
            BlockStep(
                block=BlockSpec(slot=Slot(2), parent_label="common", label="b_2"),
            ),
            BlockStep(
                block=BlockSpec(slot=Slot(3), parent_label="b_2", label="b_3"),
            ),
            # 4 out of 6 validators attest to fork B's head
            *[
                AttestationStep(
                    attestation=GossipAttestationSpec(
                        validator_id=ValidatorIndex(i),
                        slot=Slot(3),
                        target_slot=Slot(3),
                        target_root_label="b_3",
                    ),
                )
                for i in range(4)
            ],
            # Head should be fork B (heavier), not fork A (deeper)
            # TODO: tick to accept attestations, then check head
        ],
    )

How to run

uv run fill --fork=devnet --clean -n auto -k test_head_selection_by_weight

References

  • `Store._compute_lmd_ghost_head`: `src/lean_spec/subspecs/forkchoice/store.py`
  • `Store.compute_block_weights`: same file
  • LMD-GHOST: Latest Message Driven Greediest Heaviest Observed SubTree

Metadata

Metadata

Assignees

Labels

good first issueGood for newcomerstestsScope: Changes to the spec tests

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions