Skip to content

Commit 9c30436

Browse files
GrapeBaBatcoratgerclaude
authored
fix: use head state's justified checkpoint as attestation source (leanEthereum#506)
* fix: use head state's justified checkpoint as attestation source produce_attestation_data was using store.latest_justified as the attestation source, while build_block filters attestations against head_state.latest_justified. When store.latest_justified diverges from head_state.latest_justified (caused by processing a non-head-chain block that crosses the 2/3 supermajority threshold), all attestations get filtered out during block building, producing blocks with 0 attestations. Align with the 3sf-mini reference: Staker.vote() uses post_states[head].latest_justified_hash, not a global max. The store-wide latest_justified is only used for fork choice (recompute_head, compute_safe_target), never for voting. Includes genesis root correction matching build_block's existing pattern: the stored genesis state has latest_justified.root = Bytes32.zero(), but attestation validation requires roots that exist in store.blocks. * test: add regression test for attestation source divergence Verifies produce_attestation_data uses head_state.latest_justified (not store.latest_justified) when the two diverge. This scenario happens when a non-head fork advances store.latest_justified, which would cause build_block to filter out all attestations. * docs: improve attestation source documentation and test clarity Rewrite produce_attestation_data docstring and latest_justified field docs to teach readers the invariant directly rather than referencing past bugs or external implementations. Add line-by-line comments to the unit test explaining each step of the divergence scenario. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add spec test vector for attestation source divergence Fork choice test that creates a justified divergence between the store-wide global max and the head state. A gossip attestation is produced via the store's attestation production path and validated. Fails without the fix: source.slot (1) > target.slot (0) violates the monotonicity invariant. Passes with the fix: source comes from the head state (slot 0) instead of the global max. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Thomas Coratger <thomas.coratger@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0deaf57 commit 9c30436

3 files changed

Lines changed: 211 additions & 13 deletions

File tree

src/lean_spec/subspecs/forkchoice/store.py

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,14 @@ class Store(StrictBaseModel):
9797

9898
latest_justified: Checkpoint
9999
"""
100-
Highest slot justified checkpoint known to the store.
101-
102-
LMD GHOST starts from this checkpoint when computing the head.
100+
Highest-slot justified checkpoint known to the store.
103101
102+
This is the global maximum across all forks.
103+
Used only for fork choice: LMD GHOST starts here when computing the head.
104104
Only descendants of this checkpoint are considered viable.
105+
106+
Attestation production does not use this field.
107+
Validators vote with the justified checkpoint from the head state instead.
105108
"""
106109

107110
latest_finalized: Checkpoint
@@ -1186,23 +1189,40 @@ def produce_attestation_data(self, slot: Slot) -> AttestationData:
11861189
"""
11871190
Produce attestation data for the given slot.
11881191
1189-
This method constructs an AttestationData object according to the lean protocol
1190-
specification. The attestation data represents the chain state view including
1191-
head, target, and source checkpoints.
1192+
An attestation vote has four fields:
1193+
1194+
- **slot**: when this vote is cast
1195+
- **head**: the block at the tip of the chain
1196+
- **target**: the justification candidate (walked back from head)
1197+
- **source**: the last justified checkpoint *on the head chain*
11921198
1193-
The algorithm:
1194-
1. Get the current head block
1195-
2. Calculate the appropriate attestation target using current forkchoice state
1196-
3. Use the store's latest justified checkpoint as the attestation source
1197-
4. Construct and return the complete AttestationData object
1199+
The source anchors the vote to a trusted starting point. It must
1200+
come from the head state — not from the store-wide field — because
1201+
the block builder only accepts attestations whose source matches
1202+
the head state's justified checkpoint.
1203+
1204+
At genesis the head state has a zero-hash checkpoint root. Since
1205+
validation requires a root present in the block registry, the
1206+
actual genesis block root is substituted.
11981207
11991208
Args:
12001209
slot: The slot for which to produce the attestation data.
12011210
12021211
Returns:
12031212
A fully constructed AttestationData object.
12041213
"""
1205-
# Get the head block the validator sees for this slot
1214+
head_state = self.states[self.head]
1215+
1216+
# Derive the source from the head state's justified checkpoint.
1217+
#
1218+
# At genesis the checkpoint root is a placeholder zero-hash.
1219+
# Replace it with the real genesis block root so that attestation
1220+
# validation can look it up in the block registry.
1221+
if head_state.latest_block_header.slot == Slot(0):
1222+
source = head_state.latest_justified.model_copy(update={"root": self.head})
1223+
else:
1224+
source = head_state.latest_justified
1225+
12061226
head_checkpoint = Checkpoint(
12071227
root=self.head,
12081228
slot=self.blocks[self.head].slot,
@@ -1216,7 +1236,7 @@ def produce_attestation_data(self, slot: Slot) -> AttestationData:
12161236
slot=slot,
12171237
head=head_checkpoint,
12181238
target=target_checkpoint,
1219-
source=self.latest_justified,
1239+
source=source,
12201240
)
12211241

12221242
def produce_block_with_signatures(
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Attestation Source Divergence"""
2+
3+
import pytest
4+
from consensus_testing import (
5+
AggregatedAttestationSpec,
6+
AttestationStep,
7+
BlockSpec,
8+
BlockStep,
9+
ForkChoiceTestFiller,
10+
GossipAttestationSpec,
11+
StoreChecks,
12+
)
13+
14+
from lean_spec.subspecs.containers.slot import Slot
15+
from lean_spec.subspecs.containers.validator import ValidatorIndex
16+
17+
pytestmark = pytest.mark.valid_until("Devnet")
18+
19+
20+
def test_gossip_attestation_accepted_after_fork_advances_justified(
21+
fork_choice_test: ForkChoiceTestFiller,
22+
) -> None:
23+
"""
24+
Gossip attestation is valid when the global justified diverges from head.
25+
26+
Scenario
27+
--------
28+
Four validators. The chain forks at slot 1::
29+
30+
genesis ── slot 1 ("common") ─┬── slot 2 ── slot 3 (head, V0 weight)
31+
└── slot 4 (fork, V1+V2+V3 justify "common")
32+
33+
The fork block carries attestations from 3 of 4 validators for the fork
34+
point. This crosses the 2/3 threshold and advances the store-wide justified
35+
checkpoint to slot 1.
36+
37+
The head chain has not seen those votes, so its state still holds the
38+
genesis justified checkpoint (slot 0). After the fork is processed:
39+
40+
- Store-wide justified: slot 1
41+
- Head state justified: slot 0
42+
43+
A gossip attestation is then produced at slot 5. The attestation target
44+
walks back 3 slots from head (slot 3) and lands on genesis (slot 0).
45+
46+
The source must come from the head state (slot 0) so that the
47+
monotonicity invariant holds (source.slot <= target.slot). Using the
48+
store-wide value (slot 1) would violate it and be rejected.
49+
"""
50+
fork_choice_test(
51+
steps=[
52+
# Common ancestor — the fork point for both chains.
53+
BlockStep(
54+
block=BlockSpec(slot=Slot(1), label="common"),
55+
checks=StoreChecks(head_slot=Slot(1)),
56+
),
57+
# Main chain, first extension.
58+
BlockStep(
59+
block=BlockSpec(slot=Slot(2), parent_label="common", label="block_2"),
60+
checks=StoreChecks(head_slot=Slot(2)),
61+
),
62+
# Main chain, second extension.
63+
# V0 attests for block_2, giving this branch LMD-GHOST weight
64+
# so that it stays head after the fork block arrives.
65+
BlockStep(
66+
block=BlockSpec(
67+
slot=Slot(3),
68+
parent_label="block_2",
69+
label="block_3",
70+
attestations=[
71+
AggregatedAttestationSpec(
72+
validator_ids=[ValidatorIndex(0)],
73+
slot=Slot(2),
74+
target_slot=Slot(2),
75+
target_root_label="block_2",
76+
),
77+
],
78+
),
79+
checks=StoreChecks(head_slot=Slot(3)),
80+
),
81+
# Minority fork. V1, V2, V3 attest for the fork point.
82+
# 3 of 4 validators cross the 2/3 threshold, advancing the
83+
# store-wide justified checkpoint to slot 1. Head stays on
84+
# the main chain thanks to V0's weight.
85+
BlockStep(
86+
block=BlockSpec(
87+
slot=Slot(4),
88+
parent_label="common",
89+
label="fork_block",
90+
attestations=[
91+
AggregatedAttestationSpec(
92+
validator_ids=[
93+
ValidatorIndex(1),
94+
ValidatorIndex(2),
95+
ValidatorIndex(3),
96+
],
97+
slot=Slot(1),
98+
target_slot=Slot(1),
99+
target_root_label="common",
100+
),
101+
],
102+
),
103+
checks=StoreChecks(
104+
head_slot=Slot(3),
105+
head_root_label="block_3",
106+
latest_justified_slot=Slot(1),
107+
latest_justified_root_label="common",
108+
),
109+
),
110+
# Gossip attestation from V3 at slot 5.
111+
#
112+
# This exercises the attestation production code path.
113+
# The target walks back to genesis (slot 0). The source must
114+
# be slot 0 (head state) so that source <= target holds.
115+
AttestationStep(
116+
attestation=GossipAttestationSpec(
117+
validator_id=ValidatorIndex(3),
118+
slot=Slot(5),
119+
target_slot=Slot(3),
120+
target_root_label="block_3",
121+
),
122+
),
123+
],
124+
)

tests/lean_spec/subspecs/forkchoice/test_store_attestations.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from tests.lean_spec.helpers import (
2222
TEST_VALIDATOR_ID,
2323
make_aggregated_proof,
24+
make_bytes32,
2425
make_signed_block_from_store,
2526
make_store,
2627
make_store_with_attestation_data,
@@ -790,3 +791,56 @@ def test_gossip_to_aggregation_to_storage(self, key_manager: XmssKeyManager) ->
790791
message=data_root,
791792
slot=attestation_data.slot,
792793
)
794+
795+
796+
def test_produce_attestation_data_uses_head_state_justified(
797+
key_manager: XmssKeyManager,
798+
) -> None:
799+
"""
800+
Attestation source uses the head state's justified checkpoint.
801+
802+
Problem
803+
-------
804+
The store tracks the highest justified checkpoint across *all* forks.
805+
A block on a minority fork can push this global max ahead of what
806+
the head chain has seen:
807+
808+
- Global justified: slot 42 (advanced by a competing fork)
809+
- Head state justified: slot 0 (head chain hasn't seen those votes)
810+
811+
If attestation production uses the global max, block building rejects
812+
every attestation because it filters by the head state's checkpoint.
813+
Result: blocks with zero attestations, stalling justification.
814+
815+
Invariant
816+
---------
817+
The source in produced attestations must always match what the block
818+
builder expects. Both must come from the head state, not the global max.
819+
"""
820+
# Create a minimal store with 3 validators at genesis.
821+
store = make_store(num_validators=3, key_manager=key_manager)
822+
823+
# The head state's justified checkpoint is what the block builder
824+
# will filter attestations against. At genesis, the stored root is
825+
# Bytes32.zero(), so apply the same correction used in block building.
826+
head_state = store.states[store.head]
827+
head_justified = head_state.latest_justified.model_copy(update={"root": store.head})
828+
829+
# Simulate a non-head fork advancing the store's global justified
830+
# past what the head chain has seen. In practice this happens when
831+
# a minority fork's block carries enough attestations to cross the
832+
# 2/3 supermajority threshold.
833+
higher_justified = Checkpoint(root=make_bytes32(99), slot=Slot(42))
834+
diverged_store = store.model_copy(update={"latest_justified": higher_justified})
835+
836+
# Precondition: the global max is strictly ahead of the head state.
837+
assert diverged_store.latest_justified.slot > head_justified.slot
838+
839+
# Produce attestation data. The source must come from the head state
840+
# (slot 0), not from the global max (slot 42).
841+
attestation = diverged_store.produce_attestation_data(Slot(1))
842+
843+
# The source must match the head state's justified checkpoint.
844+
# Using the global max would cause a source mismatch in block building,
845+
# silently rejecting every attestation.
846+
assert attestation.source == head_justified

0 commit comments

Comments
 (0)