Skip to content

test(fc): attestation source divergence — complex multi-fork scenario #583

@tcoratger

Description

@tcoratger

Context

In the leanSpec fork choice, the attestation source is taken from the head state's justified checkpoint, not the store's global justified checkpoint. These can diverge when different forks have different justification histories.

The existing test (`test_gossip_attestation_accepted_after_fork_advances_justified` in `test_attestation_source_divergence.py`) covers a basic case. This issue extends coverage to a more complex multi-fork scenario.

Why this matters

When fork A justifies slot 5 but fork B only justifies slot 2, a validator attesting on fork B must use source=slot 2 (from B's head state), not source=slot 5 (from the store). If a client incorrectly uses the store's justified checkpoint, valid attestations on fork B would be wrongly rejected.

What to test

Write a fork choice filler that:

  1. Creates two forks with different justification histories:
    • Fork A: justifies slots 1 and 3 (more advanced)
    • Fork B: justifies only slot 1 (less advanced)
  2. The store's global justified checkpoint is at slot 3 (from fork A)
  3. Gossips an attestation for fork B with source=slot 1 (fork B's justified)
  4. Verifies the attestation is accepted (source matches fork B's head state)
  5. Gossips an attestation for fork B with source=slot 3 (store's justified, but NOT fork B's)
  6. Verifies the attestation is rejected (source mismatch with fork B's head state)

Key assertions

  • Attestation with source matching the target fork's justified: accepted
  • Attestation with source matching the store's (but not target fork's) justified: rejected
  • `StoreChecks` validates attestation storage after acceptance

Where to add the test

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

Code skeleton

def test_source_divergence_complex_multi_fork(
    fork_choice_test: ForkChoiceTestFiller,
) -> None:
    """Attestation source must match the target fork's justified, not the store's."""
    fork_choice_test(
        num_validators=6,
        steps=[
            # Build common prefix
            BlockStep(block=BlockSpec(slot=Slot(1), label="block_1")),
            # Fork A: advances justification to slot 3
            # Fork B: stays at justification slot 1
            # ...
            # Gossip attestation for fork B with correct source (slot 1)
            AttestationStep(
                attestation=GossipAttestationSpec(
                    validator_id=ValidatorIndex(5),
                    slot=Slot(...),
                    target_slot=Slot(...),
                    target_root_label="fork_b_...",
                    source_slot=Slot(1),  # Correct for fork B
                    source_root_label="block_1",
                ),
                valid=True,  # Should be accepted
            ),
            # Gossip attestation for fork B with store's source (slot 3)
            AttestationStep(
                attestation=GossipAttestationSpec(
                    validator_id=ValidatorIndex(4),
                    slot=Slot(...),
                    target_slot=Slot(...),
                    target_root_label="fork_b_...",
                    source_slot=Slot(3),  # Store's justified, not fork B's
                    source_root_label="fork_a_3",
                ),
                valid=False,
                expected_error="Source checkpoint slot mismatch",
            ),
        ],
    )

How to run

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

References

  • `Store.validate_attestation`: `src/lean_spec/subspecs/forkchoice/store.py`
  • `Store.on_gossip_attestation`: same file
  • Existing simple test: `tests/consensus/devnet/fc/test_attestation_source_divergence.py`

Metadata

Metadata

Assignees

Labels

testsScope: 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