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
46 changes: 33 additions & 13 deletions src/lean_spec/subspecs/forkchoice/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1186,23 +1189,40 @@ 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.

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,
Expand All @@ -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(
Expand Down
124 changes: 124 additions & 0 deletions tests/consensus/devnet/fc/test_attestation_source_divergence.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
],
)
54 changes: 54 additions & 0 deletions tests/lean_spec/subspecs/forkchoice/test_store_attestations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Loading