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:
- Creates two forks with different justification histories:
- Fork A: justifies slots 1 and 3 (more advanced)
- Fork B: justifies only slot 1 (less advanced)
- The store's global justified checkpoint is at slot 3 (from fork A)
- Gossips an attestation for fork B with source=slot 1 (fork B's justified)
- Verifies the attestation is accepted (source matches fork B's head state)
- Gossips an attestation for fork B with source=slot 3 (store's justified, but NOT fork B's)
- 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`
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:
Key assertions
Where to add the test
Add to: `tests/consensus/devnet/fc/test_attestation_source_divergence.py`
Code skeleton
How to run
References