Skip to content

Commit 43e670c

Browse files
tcoratgerclaude
andauthored
fix(forks): enforce distinct-attestation-data cap in state transition (leanEthereum#1102)
* fix(forks): enforce distinct-attestation-data cap in state transition The per-block cap on distinct attestation data was enforced only at the fork-choice caller, so the raw state transition and block-production trial blocks inherited an unbounded count of distinct AttestationData, each an allocation proportional to the validator count. Move the distinct-data cap into the attestation-processing step so the bound is a property of the transition itself. The wire-level duplicate prohibition stays at the fork-choice caller, since split aggregates that share one data entry are a legitimate, idempotently merged input to the transition. Add a state-transition vector for the over-cap rejection and relocate the fork-choice over-cap vector, which can no longer build such a block. Full fill stays green with the determinism check passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: tighten attestation-cap comments per documentation rules Reflow the cap-enforcement comment in the state transition and the duplicate-rejection comment in block import to one sentence per line, splitting the wrapped multi-clause sentences and trimming the pile-up of cause-effect clauses. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e835d41 commit 43e670c

4 files changed

Lines changed: 120 additions & 91 deletions

File tree

src/lean_spec/spec/forks/lstar/fork_choice.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from lean_spec.spec.forks.lstar.config import (
1111
GOSSIP_DISPARITY_INTERVALS,
1212
INTERVALS_PER_SLOT,
13-
MAX_ATTESTATIONS_DATA,
1413
)
1514
from lean_spec.spec.forks.lstar.containers import (
1615
AggregationError,
@@ -565,14 +564,15 @@ def on_block(
565564
f"Sync parent chain before processing block at slot {block.slot}.",
566565
)
567566

568-
# Bound the distinct votes the block may carry.
567+
# Reject a block body that repeats the same vote data.
569568
#
570569
# Collapsing the votes to their distinct data exposes any repeat:
571570
#
572571
# votes -> { vote A, vote B, vote B } -> distinct { vote A, vote B }
573572
#
574573
# A repeat (fewer distinct than total) is rejected as duplicate data.
575-
# More distinct votes than the cap is rejected to bound import work.
574+
# The transition itself bounds the distinct-data count.
575+
# Only the wire-level duplicate prohibition lives here.
576576
aggregated_attestations = block.body.attestations
577577
attestation_data_set = {attestation.data for attestation in aggregated_attestations}
578578
if len(attestation_data_set) != len(aggregated_attestations):
@@ -581,12 +581,6 @@ def on_block(
581581
"Block contains duplicate AttestationData entries; "
582582
"each AttestationData must appear at most once",
583583
)
584-
if len(attestation_data_set) > int(MAX_ATTESTATIONS_DATA):
585-
raise SpecRejectionError(
586-
RejectionReason.TOO_MANY_ATTESTATION_DATA,
587-
f"Block contains {len(attestation_data_set)} distinct AttestationData "
588-
f"entries; maximum is {MAX_ATTESTATIONS_DATA}",
589-
)
590584

591585
# Validate cryptographic signatures.
592586
#

src/lean_spec/spec/forks/lstar/state_transition.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from lean_spec.spec.crypto.merkleization import hash_tree_root
77
from lean_spec.spec.forks.lstar._base import LstarSpecBase
8+
from lean_spec.spec.forks.lstar.config import MAX_ATTESTATIONS_DATA
89
from lean_spec.spec.forks.lstar.containers import (
910
AggregatedAttestation,
1011
Block,
@@ -253,11 +254,34 @@ def process_attestations(
253254
Apply attestations and update justification and finalization under 3SF-mini rules.
254255
255256
Raises:
257+
SpecRejectionError: TOO_MANY_ATTESTATION_DATA if the distinct data
258+
count exceeds the per-block cap.
256259
SpecRejectionError: EMPTY_AGGREGATION_BITS if an attestation that passes
257260
the vote filters has no set bits.
258261
SpecRejectionError: VALIDATOR_INDEX_OUT_OF_RANGE if a set bit points
259262
outside the validator registry.
260263
"""
264+
# Bound the distinct votes the block may carry.
265+
#
266+
# The cap belongs to the transition itself, not to any one caller.
267+
# Both the state transition and block-production trial blocks rely on it.
268+
#
269+
# Each distinct attestation data builds a tally sized to the validator set.
270+
# An unbounded count of them amplifies import work.
271+
# The SSZ list limit sits far above the consensus cap, so it cannot substitute.
272+
#
273+
# Only the distinct count is bounded, not the total.
274+
# Split aggregates for one target share their data and count once.
275+
# Re-marking a voter from a repeated entry is idempotent, so it stays valid.
276+
aggregated_attestations = tuple(attestations)
277+
distinct_attestation_data = {attestation.data for attestation in aggregated_attestations}
278+
if len(distinct_attestation_data) > int(MAX_ATTESTATIONS_DATA):
279+
raise SpecRejectionError(
280+
RejectionReason.TOO_MANY_ATTESTATION_DATA,
281+
f"Block contains {len(distinct_attestation_data)} distinct AttestationData "
282+
f"entries; maximum is {MAX_ATTESTATIONS_DATA}",
283+
)
284+
261285
# Reconstruct the vote-tracking structure
262286
#
263287
# The state stores justification data in a compact SSZ layout:
@@ -312,7 +336,7 @@ def process_attestations(
312336
# "I vote to extend the chain from SOURCE to TARGET."
313337
#
314338
# The rules below filter out invalid or irrelevant votes.
315-
for attestation in attestations:
339+
for attestation in aggregated_attestations:
316340
source = attestation.data.source
317341
target = attestation.data.target
318342

tests/consensus/lstar/fork_choice/test_block_attestation_limits.py

Lines changed: 1 addition & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,11 @@
66
AggregatedAttestationSpec,
77
BlockSpec,
88
BlockStep,
9-
ExpectedRejection,
109
ForkChoiceStep,
1110
ForkChoiceTestFiller,
1211
StoreChecks,
1312
)
14-
from lean_spec.spec.forks import RejectionReason, Slot, ValidatorIndex
13+
from lean_spec.spec.forks import Slot, ValidatorIndex
1514
from lean_spec.spec.forks.lstar.config import MAX_ATTESTATIONS_DATA
1615

1716
pytestmark = pytest.mark.valid_until("Lstar")
@@ -88,82 +87,3 @@ def test_block_with_maximum_attestations(
8887
fork_choice_test(
8988
steps=chain,
9089
)
91-
92-
93-
def test_block_exceeding_maximum_attestations_is_rejected(
94-
fork_choice_test: ForkChoiceTestFiller,
95-
) -> None:
96-
"""
97-
A block holding one more than the maximum number of distinct votes is rejected.
98-
99-
Given
100-
-----
101-
- 4 validators; a slot needs 3 votes (2/3) to be justified.
102-
- the chain:
103-
genesis -> one block per justifiable slot after genesis
104-
- the chain holds one more justifiable slot than the maximum allows.
105-
106-
When
107-
----
108-
- a final block carries the maximum number of votes from the builder.
109-
- one forced vote pushes the count one over the limit.
110-
111-
Then
112-
----
113-
- the store rejects the block for exceeding the distinct attestation data limit.
114-
"""
115-
n = int(MAX_ATTESTATIONS_DATA)
116-
targets = _justifiable_slots(n + 1)
117-
proposal_slot = Slot(targets[-1] + Slot(1))
118-
119-
chain: list[ForkChoiceStep] = [
120-
BlockStep(
121-
block=BlockSpec(
122-
slot=s,
123-
label=f"b_{s}",
124-
parent_label=f"b_{targets[i - 1]}" if i > 0 else None,
125-
)
126-
)
127-
for i, s in enumerate(targets)
128-
]
129-
130-
builder_targets = targets[:n]
131-
forced_target = targets[n]
132-
133-
chain.append(
134-
BlockStep(
135-
block=BlockSpec(
136-
slot=proposal_slot,
137-
parent_label=f"b_{targets[-1]}",
138-
attestations=[
139-
AggregatedAttestationSpec(
140-
validator_indices=[ValidatorIndex(i % 4)],
141-
slot=proposal_slot,
142-
target_slot=s,
143-
target_root_label=f"b_{s}",
144-
)
145-
for i, s in enumerate(builder_targets)
146-
],
147-
forced_attestations=[
148-
AggregatedAttestationSpec(
149-
validator_indices=[ValidatorIndex(0)],
150-
slot=proposal_slot,
151-
target_slot=forced_target,
152-
target_root_label=f"b_{forced_target}",
153-
),
154-
],
155-
),
156-
valid=False,
157-
expected_rejection=ExpectedRejection(
158-
reason=RejectionReason.TOO_MANY_ATTESTATION_DATA,
159-
exact_message=(
160-
f"Block contains {n + 1} distinct AttestationData entries; "
161-
f"maximum is {MAX_ATTESTATIONS_DATA}"
162-
),
163-
),
164-
)
165-
)
166-
167-
fork_choice_test(
168-
steps=chain,
169-
)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""State Transition: Attestation Data Limits"""
2+
3+
import pytest
4+
5+
from consensus_testing import (
6+
AggregatedAttestationSpec,
7+
BlockSpec,
8+
ExpectedRejection,
9+
StateTransitionTestFiller,
10+
)
11+
from lean_spec.spec.forks import RejectionReason, Slot, ValidatorIndex
12+
from lean_spec.spec.forks.lstar.config import MAX_ATTESTATIONS_DATA
13+
14+
pytestmark = pytest.mark.valid_until("Lstar")
15+
16+
17+
def _justifiable_slots(count: int) -> list[Slot]:
18+
"""Return the first COUNT justifiable slots after finalized genesis (slot 0)."""
19+
justifiable_slots: list[Slot] = []
20+
candidate_slot = Slot(1)
21+
while len(justifiable_slots) < count:
22+
if candidate_slot.is_justifiable_after(Slot(0)):
23+
justifiable_slots.append(candidate_slot)
24+
candidate_slot = Slot(candidate_slot + Slot(1))
25+
return justifiable_slots
26+
27+
28+
def test_block_exceeding_distinct_attestation_data_cap_rejects_block(
29+
state_transition_test: StateTransitionTestFiller,
30+
) -> None:
31+
"""
32+
A block carrying more distinct attestation data than the cap rejects in the transition.
33+
34+
Given
35+
-----
36+
- 4 validators.
37+
- the cap on distinct attestation data per block is 8.
38+
- the chain:
39+
genesis -> one block per justifiable slot, one more than the cap allows
40+
- the final block carries one forced vote per distinct target.
41+
- the forced votes bypass the proposer-side builder cap.
42+
43+
When
44+
----
45+
- the chain processes the final block.
46+
47+
Then
48+
----
49+
- the block is rejected with TOO_MANY_ATTESTATION_DATA.
50+
- the count over the cap is 9 distinct entries against a maximum of 8.
51+
"""
52+
over_cap_count = int(MAX_ATTESTATIONS_DATA) + 1
53+
target_slots = _justifiable_slots(over_cap_count)
54+
proposal_slot = Slot(target_slots[-1] + Slot(1))
55+
56+
chain: list[BlockSpec] = [
57+
BlockSpec(
58+
slot=target_slot,
59+
label=f"block_{target_slot}",
60+
parent_label=f"block_{target_slots[position - 1]}" if position > 0 else None,
61+
)
62+
for position, target_slot in enumerate(target_slots)
63+
]
64+
65+
chain.append(
66+
BlockSpec(
67+
slot=proposal_slot,
68+
parent_label=f"block_{target_slots[-1]}",
69+
forced_attestations=[
70+
AggregatedAttestationSpec(
71+
validator_indices=[ValidatorIndex(position % 4)],
72+
slot=proposal_slot,
73+
target_slot=target_slot,
74+
target_root_label=f"block_{target_slot}",
75+
)
76+
for position, target_slot in enumerate(target_slots)
77+
],
78+
)
79+
)
80+
81+
state_transition_test(
82+
blocks=chain,
83+
post=None,
84+
expected_rejection=ExpectedRejection(
85+
reason=RejectionReason.TOO_MANY_ATTESTATION_DATA,
86+
exact_message=(
87+
f"Block contains {over_cap_count} distinct AttestationData "
88+
f"entries; maximum is {MAX_ATTESTATIONS_DATA}"
89+
),
90+
),
91+
)

0 commit comments

Comments
 (0)