diff --git a/src/lean_spec/subspecs/forkchoice/store.py b/src/lean_spec/subspecs/forkchoice/store.py index b037fcd2..b9b5e6f0 100644 --- a/src/lean_spec/subspecs/forkchoice/store.py +++ b/src/lean_spec/subspecs/forkchoice/store.py @@ -97,11 +97,14 @@ class Store(StrictBaseModel): latest_justified: Checkpoint """ - Highest slot justified checkpoint known to the store. - - LMD GHOST starts from this checkpoint when computing the head. + Highest-slot justified checkpoint known to the store. + This is the global maximum across all forks. + Used only for fork choice: LMD GHOST starts here when computing the head. Only descendants of this checkpoint are considered viable. + + Attestation production does not use this field. + Validators vote with the justified checkpoint from the head state instead. """ latest_finalized: Checkpoint @@ -1186,15 +1189,21 @@ def produce_attestation_data(self, slot: Slot) -> AttestationData: """ Produce attestation data for the given slot. - This method constructs an AttestationData object according to the lean protocol - specification. The attestation data represents the chain state view including - head, target, and source checkpoints. + An attestation vote has four fields: + + - **slot**: when this vote is cast + - **head**: the block at the tip of the chain + - **target**: the justification candidate (walked back from head) + - **source**: the last justified checkpoint *on the head chain* - The algorithm: - 1. Get the current head block - 2. Calculate the appropriate attestation target using current forkchoice state - 3. Use the store's latest justified checkpoint as the attestation source - 4. Construct and return the complete AttestationData object + The source anchors the vote to a trusted starting point. It must + come from the head state — not from the store-wide field — because + the block builder only accepts attestations whose source matches + the head state's justified checkpoint. + + At genesis the head state has a zero-hash checkpoint root. Since + validation requires a root present in the block registry, the + actual genesis block root is substituted. Args: slot: The slot for which to produce the attestation data. @@ -1202,7 +1211,18 @@ def produce_attestation_data(self, slot: Slot) -> AttestationData: Returns: A fully constructed AttestationData object. """ - # Get the head block the validator sees for this slot + head_state = self.states[self.head] + + # Derive the source from the head state's justified checkpoint. + # + # At genesis the checkpoint root is a placeholder zero-hash. + # Replace it with the real genesis block root so that attestation + # validation can look it up in the block registry. + if head_state.latest_block_header.slot == Slot(0): + source = head_state.latest_justified.model_copy(update={"root": self.head}) + else: + source = head_state.latest_justified + head_checkpoint = Checkpoint( root=self.head, slot=self.blocks[self.head].slot, @@ -1216,7 +1236,7 @@ def produce_attestation_data(self, slot: Slot) -> AttestationData: slot=slot, head=head_checkpoint, target=target_checkpoint, - source=self.latest_justified, + source=source, ) def produce_block_with_signatures( diff --git a/tests/consensus/devnet/fc/test_attestation_source_divergence.py b/tests/consensus/devnet/fc/test_attestation_source_divergence.py new file mode 100644 index 00000000..5dc3a10b --- /dev/null +++ b/tests/consensus/devnet/fc/test_attestation_source_divergence.py @@ -0,0 +1,124 @@ +"""Attestation Source Divergence""" + +import pytest +from consensus_testing import ( + AggregatedAttestationSpec, + AttestationStep, + BlockSpec, + BlockStep, + ForkChoiceTestFiller, + GossipAttestationSpec, + StoreChecks, +) + +from lean_spec.subspecs.containers.slot import Slot +from lean_spec.subspecs.containers.validator import ValidatorIndex + +pytestmark = pytest.mark.valid_until("Devnet") + + +def test_gossip_attestation_accepted_after_fork_advances_justified( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + Gossip attestation is valid when the global justified diverges from head. + + Scenario + -------- + Four validators. The chain forks at slot 1:: + + genesis ── slot 1 ("common") ─┬── slot 2 ── slot 3 (head, V0 weight) + └── slot 4 (fork, V1+V2+V3 justify "common") + + The fork block carries attestations from 3 of 4 validators for the fork + point. This crosses the 2/3 threshold and advances the store-wide justified + checkpoint to slot 1. + + The head chain has not seen those votes, so its state still holds the + genesis justified checkpoint (slot 0). After the fork is processed: + + - Store-wide justified: slot 1 + - Head state justified: slot 0 + + A gossip attestation is then produced at slot 5. The attestation target + walks back 3 slots from head (slot 3) and lands on genesis (slot 0). + + The source must come from the head state (slot 0) so that the + monotonicity invariant holds (source.slot <= target.slot). Using the + store-wide value (slot 1) would violate it and be rejected. + """ + fork_choice_test( + steps=[ + # Common ancestor — the fork point for both chains. + BlockStep( + block=BlockSpec(slot=Slot(1), label="common"), + checks=StoreChecks(head_slot=Slot(1)), + ), + # Main chain, first extension. + BlockStep( + block=BlockSpec(slot=Slot(2), parent_label="common", label="block_2"), + checks=StoreChecks(head_slot=Slot(2)), + ), + # Main chain, second extension. + # V0 attests for block_2, giving this branch LMD-GHOST weight + # so that it stays head after the fork block arrives. + BlockStep( + block=BlockSpec( + slot=Slot(3), + parent_label="block_2", + label="block_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(0)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="block_2", + ), + ], + ), + checks=StoreChecks(head_slot=Slot(3)), + ), + # Minority fork. V1, V2, V3 attest for the fork point. + # 3 of 4 validators cross the 2/3 threshold, advancing the + # store-wide justified checkpoint to slot 1. Head stays on + # the main chain thanks to V0's weight. + BlockStep( + block=BlockSpec( + slot=Slot(4), + parent_label="common", + label="fork_block", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ + ValidatorIndex(1), + ValidatorIndex(2), + ValidatorIndex(3), + ], + slot=Slot(1), + target_slot=Slot(1), + target_root_label="common", + ), + ], + ), + checks=StoreChecks( + head_slot=Slot(3), + head_root_label="block_3", + latest_justified_slot=Slot(1), + latest_justified_root_label="common", + ), + ), + # Gossip attestation from V3 at slot 5. + # + # This exercises the attestation production code path. + # The target walks back to genesis (slot 0). The source must + # be slot 0 (head state) so that source <= target holds. + AttestationStep( + attestation=GossipAttestationSpec( + validator_id=ValidatorIndex(3), + slot=Slot(5), + target_slot=Slot(3), + target_root_label="block_3", + ), + ), + ], + ) diff --git a/tests/lean_spec/subspecs/forkchoice/test_store_attestations.py b/tests/lean_spec/subspecs/forkchoice/test_store_attestations.py index 152adb34..2786f29f 100644 --- a/tests/lean_spec/subspecs/forkchoice/test_store_attestations.py +++ b/tests/lean_spec/subspecs/forkchoice/test_store_attestations.py @@ -21,6 +21,7 @@ from tests.lean_spec.helpers import ( TEST_VALIDATOR_ID, make_aggregated_proof, + make_bytes32, make_signed_block_from_store, make_store, make_store_with_attestation_data, @@ -790,3 +791,56 @@ def test_gossip_to_aggregation_to_storage(self, key_manager: XmssKeyManager) -> message=data_root, slot=attestation_data.slot, ) + + +def test_produce_attestation_data_uses_head_state_justified( + key_manager: XmssKeyManager, +) -> None: + """ + Attestation source uses the head state's justified checkpoint. + + Problem + ------- + The store tracks the highest justified checkpoint across *all* forks. + A block on a minority fork can push this global max ahead of what + the head chain has seen: + + - Global justified: slot 42 (advanced by a competing fork) + - Head state justified: slot 0 (head chain hasn't seen those votes) + + If attestation production uses the global max, block building rejects + every attestation because it filters by the head state's checkpoint. + Result: blocks with zero attestations, stalling justification. + + Invariant + --------- + The source in produced attestations must always match what the block + builder expects. Both must come from the head state, not the global max. + """ + # Create a minimal store with 3 validators at genesis. + store = make_store(num_validators=3, key_manager=key_manager) + + # The head state's justified checkpoint is what the block builder + # will filter attestations against. At genesis, the stored root is + # Bytes32.zero(), so apply the same correction used in block building. + head_state = store.states[store.head] + head_justified = head_state.latest_justified.model_copy(update={"root": store.head}) + + # Simulate a non-head fork advancing the store's global justified + # past what the head chain has seen. In practice this happens when + # a minority fork's block carries enough attestations to cross the + # 2/3 supermajority threshold. + higher_justified = Checkpoint(root=make_bytes32(99), slot=Slot(42)) + diverged_store = store.model_copy(update={"latest_justified": higher_justified}) + + # Precondition: the global max is strictly ahead of the head state. + assert diverged_store.latest_justified.slot > head_justified.slot + + # Produce attestation data. The source must come from the head state + # (slot 0), not from the global max (slot 42). + attestation = diverged_store.produce_attestation_data(Slot(1)) + + # The source must match the head state's justified checkpoint. + # Using the global max would cause a source mismatch in block building, + # silently rejecting every attestation. + assert attestation.source == head_justified