Skip to content
Closed
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
16 changes: 14 additions & 2 deletions src/lean_spec/subspecs/forkchoice/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -1195,7 +1195,12 @@ def produce_attestation_data(self, slot: Slot) -> AttestationData:
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
3. Use the head state's latest justified checkpoint as the attestation source
(matching 3sf-mini's original design where vote source comes from the head
block's post-state, not from the store-wide max). This ensures that
build_block's filter (post_state.latest_justified) always matches on the
first iteration, since an empty candidate block over head_state preserves
head_state.latest_justified.
4. Construct and return the complete AttestationData object

Args:
Expand All @@ -1213,12 +1218,19 @@ def produce_attestation_data(self, slot: Slot) -> AttestationData:
# Calculate the target checkpoint for this attestation
target_checkpoint = self.get_attestation_target()

# Use the head state's justified checkpoint as source, matching 3sf-mini.
# store.latest_justified can advance past head_state.latest_justified when
# a non-head-chain block's state transition crosses the 2/3 threshold.
# Using store.latest_justified here would cause a mismatch with build_block's
# filter (post_state.latest_justified), resulting in 0-attestation blocks.
head_state = self.states[self.head]

# Construct attestation data
return AttestationData(
slot=slot,
head=head_checkpoint,
target=target_checkpoint,
source=self.latest_justified,
source=head_state.latest_justified,
)
Comment on lines +1221 to 1234
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using head_state.latest_justified as the attestation source can produce an invalid source root at genesis: the anchor State is stored with latest_justified.root == Bytes32.zero() (see State.generate_genesis), while Store.validate_attestation() requires data.source.root in self.blocks. With this change, produce_attestation_data() at genesis would emit a source checkpoint whose root is not in self.blocks, causing gossip validation (and aggregation/block building paths) to fail. Consider anchoring the stored head state’s checkpoints to the anchor root in Store.from_anchor (update the stored state.latest_justified/latest_finalized roots), or otherwise ensure the returned source.root always refers to a known block root (e.g., fallback when head_state.latest_justified.root is unknown).

Copilot uses AI. Check for mistakes.

def produce_block_with_signatures(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""
Tests for attestation source consistency between voting and block building.

Regression test for a bug where produce_attestation_data used store.latest_justified
as the attestation source, while build_block filtered attestations against
post_state.latest_justified. When store.latest_justified diverged from the head
state's latest_justified (caused by processing a non-head-chain block that crosses
the 2/3 supermajority threshold), all attestations would be filtered out during
block building, producing blocks with 0 attestations.

The fix aligns produce_attestation_data with the original 3sf-mini design: the
attestation source comes from the head block's post-state, not from the store-wide
max.

See: ethereum/research 3sf-mini/p2p.py — Staker.vote() uses
self.post_states[self.head].latest_justified_hash, not a store-wide value.
"""

from __future__ import annotations

from lean_spec.subspecs.containers.checkpoint import Checkpoint
from lean_spec.subspecs.containers.slot import Slot
from lean_spec.types import Bytes32
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bytes32 is imported but never used in this test module; with ruff enabled (F401), this will fail linting. Please remove the unused import.

Suggested change
from lean_spec.types import Bytes32

Copilot uses AI. Check for mistakes.

from tests.lean_spec.helpers import (
TEST_VALIDATOR_ID,
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TEST_VALIDATOR_ID is imported but never used in this test module; with ruff enabled (F401), this will fail linting. Please remove the unused import.

Suggested change
TEST_VALIDATOR_ID,

Copilot uses AI. Check for mistakes.
make_bytes32,
make_store,
)


class TestAttestationSourceUsesHeadState:
"""
Verify that produce_attestation_data uses the head state's justified
checkpoint rather than the store's (which may be higher).

In 3sf-mini, the voting source is always head_state.latest_justified.
leanSpec must match this behavior to avoid a source mismatch with
build_block's post_state.latest_justified filter.
"""

def test_attestation_source_equals_head_state_justified_at_genesis(self) -> None:
"""At genesis, store and head state justified are both slot 0 — trivially consistent."""
store = make_store(num_validators=4)
att_data = store.produce_attestation_data(Slot(1))

head_state = store.states[store.head]

assert att_data.source == head_state.latest_justified, (
"Attestation source should equal head state's latest justified"
)

def test_attestation_source_ignores_diverged_store_justified(self) -> None:
"""
When store.latest_justified is artificially higher than head_state.latest_justified,
produce_attestation_data must still use head_state.latest_justified.

This is the core regression test. Before the fix, produce_attestation_data
returned source=store.latest_justified, which would cause build_block to
filter out every attestation because post_state.latest_justified (starting
from head_state for an empty candidate block) was lower.
"""
store = make_store(num_validators=4)
head_state = store.states[store.head]

# Sanity: at genesis, latest_justified is at slot 0 with genesis root
assert head_state.latest_justified.slot == Slot(0)

# Simulate store.latest_justified advancing past head state
# (as if a non-head-chain block's state transition justified slot 5)
fake_justified = Checkpoint(root=make_bytes32(42), slot=Slot(5))
diverged_store = store.model_copy(update={"latest_justified": fake_justified})

# Precondition: store justified is now higher than head state's
assert diverged_store.latest_justified.slot > head_state.latest_justified.slot

att_data = diverged_store.produce_attestation_data(Slot(1))

# The attestation source MUST match head_state's justified, not store's
assert att_data.source == head_state.latest_justified, (
f"Attestation source should be head_state.latest_justified "
f"(slot={head_state.latest_justified.slot}), "
f"not store.latest_justified (slot={diverged_store.latest_justified.slot})"
)
assert att_data.source != fake_justified, (
"Attestation source must NOT use the diverged store justified"
)

def test_attestation_source_matches_build_block_filter(self) -> None:
"""
The attestation source must always match what build_block uses as its
initial filter (post_state.latest_justified for an empty candidate block,
which equals head_state.latest_justified).

This ensures the first iteration of build_block's fixed-point loop can
include attestations created by produce_attestation_data.
"""
store = make_store(num_validators=4)

# Diverge store justified
fake_justified = Checkpoint(root=make_bytes32(99), slot=Slot(10))
diverged_store = store.model_copy(update={"latest_justified": fake_justified})

att_data = diverged_store.produce_attestation_data(Slot(1))

# Simulate what build_block does on first iteration:
# process an empty block on head_state -> post_state.latest_justified
# should equal head_state.latest_justified (empty block can't advance justified)
head_state = diverged_store.states[diverged_store.head]
build_block_initial_filter = head_state.latest_justified

assert att_data.source == build_block_initial_filter, (
Comment on lines +106 to +112
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test’s “build_block initial filter” simulation uses head_state.latest_justified, but State.build_block() actually filters attestations against post_state.latest_justified after processing the candidate block. At genesis-parent (slot 1), process_block_header can update the justified checkpoint root even for an empty block, so post_state.latest_justified is not guaranteed to equal head_state.latest_justified. To avoid a false sense of coverage, compute the filter the same way as build_block does (derive post_state from an empty candidate block) and assert the produced att_data.source matches that value; also consider asserting store.validate_attestation(Attestation(...)) passes for the produced data.

Copilot uses AI. Check for mistakes.
"Attestation source must match build_block's initial filter "
"(head_state.latest_justified) to avoid 0-attestation blocks"
)
Loading