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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from collections import defaultdict

from lean_spec.subspecs.chain.clock import Interval
from lean_spec.subspecs.containers.attestation import (
AggregatedAttestation,
Attestation,
Expand Down Expand Up @@ -420,6 +421,13 @@ def build_signed_block_with_store(
parent_state, block_registry, key_manager
)

# Advance the local store clock to the block's slot before gossiping.
# In-body attestations carry data.slot = self.slot; the Store's time
# check rejects votes whose slot has not yet started locally.
block_slot_interval = Interval.from_slot(self.slot)
if store.time < block_slot_interval:
store, _ = store.on_tick(block_slot_interval, has_proposal=True, is_aggregator=True)

# Gossip valid attestation signatures into the Store.
# This runs signature verification through the spec's validation path.
for attestation in valid_attestations:
Expand Down
10 changes: 10 additions & 0 deletions src/lean_spec/subspecs/chain/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@
INTERVALS_PER_SLOT: Final = Uint64(5)
"""Number of intervals per slot for forkchoice processing."""

GOSSIP_DISPARITY_INTERVALS: Final = Uint64(1)
"""
Future-slot tolerance for gossip attestations, in intervals.
Bounds the clock skew the time check is willing to absorb when admitting
a vote whose slot has not yet started locally.
One interval is roughly 800 ms.
"""

SECONDS_PER_SLOT: Final = Uint64(4)
"""The fixed duration of a single slot in seconds."""

Expand Down
17 changes: 12 additions & 5 deletions src/lean_spec/subspecs/forkchoice/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from lean_spec.subspecs.chain.clock import Interval
from lean_spec.subspecs.chain.config import (
GOSSIP_DISPARITY_INTERVALS,
INTERVALS_PER_SLOT,
JUSTIFICATION_LOOKBACK_SLOTS,
MAX_ATTESTATIONS_DATA,
Expand Down Expand Up @@ -281,7 +282,7 @@ def validate_attestation(self, attestation_data: AttestationData) -> None:
2. A vote cannot span backwards in time (source > target).
3. The head must be at least as recent as source and target.
4. Checkpoint slots must match the actual block slots.
5. A vote cannot be for a future slot.
5. The vote's slot must have started locally (a small disparity margin is allowed).

Args:
attestation_data: AttestationData whose checkpoints and slot should be validated.
Expand Down Expand Up @@ -317,10 +318,16 @@ def validate_attestation(self, attestation_data: AttestationData) -> None:

# Time Check
#
# Validate attestation is not too far in the future
# We allow a small margin for clock disparity (1 slot), but no further.
current_slot = Slot(self.time // INTERVALS_PER_SLOT)
assert data.slot <= current_slot + Slot(1), "Attestation too far in future"
# Honest validators emit votes only after their slot has begun.
# Allow a small disparity margin for clock skew between peers.
#
# The bound is in intervals, not slots: a whole-slot margin would
# let an adversary pre-publish next-slot aggregates ahead of any
# honest validator.
attestation_start_interval = Interval.from_slot(data.slot)
assert attestation_start_interval <= self.time + GOSSIP_DISPARITY_INTERVALS, (
"Attestation too far in future"
)

def on_gossip_attestation(
self,
Expand Down
41 changes: 24 additions & 17 deletions tests/consensus/devnet/fc/test_block_production.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ def test_block_builder_fixed_point_advances_justification(

Scenario
--------
Four validators. Linear chain through slot 4::
Four validators. Linear chain through slot 5::

genesis(0) -> block_1(1) -> block_2(2) -> block_3(3) -> block_4(4)
genesis(0) -> block_1(1) -> block_2(2) -> block_3(3) -> block_4(4) -> block_5(5)

Two gossip attestations with different sources:

Expand Down Expand Up @@ -66,7 +66,7 @@ def test_block_builder_fixed_point_advances_justification(
# Chain setup
# ===========
#
# genesis(0) -> block_1(1) -> block_2(2) -> block_3(3) -> block_4(4)
# genesis(0) -> block_1(1) -> block_2(2) -> block_3(3) -> block_4(4) -> block_5(5)
#
# Slot 3 carries a supermajority attestation that justifies slot 1.
# This establishes the baseline: justified=1, finalized=0.
Expand Down Expand Up @@ -104,14 +104,21 @@ def test_block_builder_fixed_point_advances_justification(
latest_finalized_slot=Slot(0),
),
),
# Extend to slot 4. No attestations, no checkpoint change.
# Extend to slot 4 then slot 5. No attestations, no checkpoint change.
BlockStep(
block=BlockSpec(slot=Slot(4), label="block_4"),
checks=StoreChecks(
head_slot=Slot(4),
latest_justified_slot=Slot(1),
),
),
BlockStep(
block=BlockSpec(slot=Slot(5), label="block_5"),
checks=StoreChecks(
head_slot=Slot(5),
latest_justified_slot=Slot(1),
),
),
# Attestation delivery
# ====================
#
Expand All @@ -125,14 +132,14 @@ def test_block_builder_fixed_point_advances_justification(
#
# Timing:
#
# 18s = interval 22 = slot 4, interval 2 (aggregate interval)
# 22s = interval 27 = slot 5, interval 2 (aggregate interval)
# The attestation pool is empty here, so nothing is lost.
#
# 20s = interval 25 = slot 5, interval 0
# Passes through interval 24 (slot 4, interval 4) which
# 24s = interval 30 = slot 6, interval 0
# Passes through interval 29 (slot 5, interval 4) which
# migrates attestations from the "new" pool to "known".
# Advance past the aggregate interval while the pool is empty.
TickStep(time=18),
# Advance to the aggregate interval while the pool is empty.
TickStep(time=22),
# Attestation A: source=1, target=2
# 3/4 validators. Matches justified=1 on the first pass.
GossipAggregatedAttestationStep(
Expand Down Expand Up @@ -166,7 +173,7 @@ def test_block_builder_fixed_point_advances_justification(
source_slot=Slot(2),
),
),
# Migrate attestations: tick to 20s (interval 24 fires acceptance).
# Migrate attestations: tick to 24s (interval 29 fires acceptance).
#
# Invariant: justified is still slot 1.
# Attestations are stored but not processed until a block is built.
Expand All @@ -175,7 +182,7 @@ def test_block_builder_fixed_point_advances_justification(
# - V0 appears only in A (source=1, target=2)
# - V3 appears only in B (source=2, target=4)
TickStep(
time=20,
time=24,
checks=StoreChecks(
latest_justified_slot=Slot(1),
attestation_checks=[
Expand Down Expand Up @@ -217,9 +224,9 @@ def test_block_builder_fixed_point_advances_justification(
# finalized = 1
# block body = 2 aggregated attestations
BlockStep(
block=BlockSpec(slot=Slot(5), label="block_5"),
block=BlockSpec(slot=Slot(6), label="block_6"),
checks=StoreChecks(
head_slot=Slot(5),
head_slot=Slot(6),
latest_justified_slot=Slot(4),
latest_justified_root_label="block_4",
latest_finalized_slot=Slot(1),
Expand Down Expand Up @@ -303,7 +310,7 @@ def test_produce_block_enforces_max_attestations_data_limit(
GossipAggregatedAttestationStep(
attestation=GossipAggregatedAttestationSpec(
validator_ids=validators,
slot=Slot(block_production_slot),
slot=Slot(num_target_blocks),
target_slot=Slot(n),
target_root_label=f"block_{n}",
),
Expand Down Expand Up @@ -380,11 +387,11 @@ def test_produce_block_includes_pending_attestations(
# Advance past the aggregate interval while the pool is empty.
TickStep(time=10),
# Validators 1 & 2 gossip an aggregated attestation targeting block_2.
# slot=3 is one slot ahead of current (slot 2): within the allowed margin.
# data.slot=2 matches the current slot.
GossipAggregatedAttestationStep(
attestation=GossipAggregatedAttestationSpec(
validator_ids=[ValidatorIndex(1), ValidatorIndex(2)],
slot=Slot(3),
slot=Slot(2),
target_slot=Slot(2),
target_root_label="block_2",
),
Expand All @@ -402,7 +409,7 @@ def test_produce_block_includes_pending_attestations(
block_attestations=[
AggregatedAttestationCheck(
participants={1, 2},
attestation_slot=Slot(3),
attestation_slot=Slot(2),
target_slot=Slot(2),
),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,25 @@
GossipAggregatedAttestationSpec,
GossipAggregatedAttestationStep,
StoreChecks,
TickStep,
)

from lean_spec.subspecs.chain.clock import Interval
from lean_spec.subspecs.chain.config import GOSSIP_DISPARITY_INTERVALS
from lean_spec.subspecs.containers.slot import Slot
from lean_spec.subspecs.containers.validator import ValidatorIndex
from lean_spec.types import Bytes32

pytestmark = pytest.mark.valid_until("Devnet")


SLOT_3_BOUNDARY_INTERVAL = int(Interval.from_slot(Slot(3)) - GOSSIP_DISPARITY_INTERVALS)
"""Latest local interval that still admits a slot-3 aggregate."""

SLOT_3_JUST_BEYOND_BOUNDARY_INTERVAL = SLOT_3_BOUNDARY_INTERVAL - 1
"""First local interval that rejects a slot-3 aggregate."""


def test_valid_gossip_aggregated_attestation(
fork_choice_test: ForkChoiceTestFiller,
) -> None:
Expand Down Expand Up @@ -169,7 +179,7 @@ def test_aggregated_attestation_source_after_target_rejected(
def test_aggregated_attestation_too_far_in_future_rejected(
fork_choice_test: ForkChoiceTestFiller,
) -> None:
"""Attestations that are too far in the future are rejected."""
"""Attestations whose slot is multiple slots ahead of local time are rejected."""
fork_choice_test(
steps=[
BlockStep(
Expand All @@ -192,3 +202,126 @@ def test_aggregated_attestation_too_far_in_future_rejected(
),
]
)


def test_aggregated_attestation_at_disparity_boundary_allowed(
fork_choice_test: ForkChoiceTestFiller,
) -> None:
"""
Aggregate exactly at the disparity boundary is allowed.

Scenario
--------
Build a chain through slot 2.
Tick to the latest local interval that still admits a slot-3 vote.
Gossip a slot-3 aggregate.

Expected:

- Aggregate is validated and stored.
"""
fork_choice_test(
steps=[
BlockStep(
block=BlockSpec(slot=Slot(1), label="block_1"),
checks=StoreChecks(head_slot=Slot(1)),
),
BlockStep(
block=BlockSpec(slot=Slot(2), label="block_2"),
checks=StoreChecks(head_slot=Slot(2)),
),
TickStep(interval=SLOT_3_BOUNDARY_INTERVAL),
GossipAggregatedAttestationStep(
attestation=GossipAggregatedAttestationSpec(
validator_ids=[ValidatorIndex(1)],
slot=Slot(3),
target_slot=Slot(2),
target_root_label="block_2",
),
),
]
)


def test_aggregated_attestation_just_beyond_disparity_boundary_rejected(
fork_choice_test: ForkChoiceTestFiller,
) -> None:
"""
Aggregate one interval beyond the disparity boundary is rejected.

Scenario
--------
Build a chain through slot 2.
Tick to one interval before the disparity boundary for a slot-3 vote.
Gossip a slot-3 aggregate.

Expected:

- Validation fails with "Attestation too far in future" error.
"""
fork_choice_test(
steps=[
BlockStep(
block=BlockSpec(slot=Slot(1), label="block_1"),
checks=StoreChecks(head_slot=Slot(1)),
),
BlockStep(
block=BlockSpec(slot=Slot(2), label="block_2"),
checks=StoreChecks(head_slot=Slot(2)),
),
TickStep(interval=SLOT_3_JUST_BEYOND_BOUNDARY_INTERVAL),
GossipAggregatedAttestationStep(
attestation=GossipAggregatedAttestationSpec(
validator_ids=[ValidatorIndex(1)],
slot=Slot(3),
target_slot=Slot(2),
target_root_label="block_2",
),
valid=False,
expected_error="Attestation too far in future",
),
]
)


def test_aggregated_attestation_one_full_slot_in_future_rejected(
fork_choice_test: ForkChoiceTestFiller,
) -> None:
"""
Aggregate a full slot ahead of local time is rejected.

Regression: an earlier rule admitted aggregates up to a full slot ahead.
That window let an adversary pre-publish next-slot aggregates before
any honest validator could produce them.

Scenario
--------
Build a chain through slot 2.
At slot-2 interval 0, gossip a slot-3 aggregate (5 intervals ahead).

Expected:

- Validation fails with "Attestation too far in future" error.
"""
fork_choice_test(
steps=[
BlockStep(
block=BlockSpec(slot=Slot(1), label="block_1"),
checks=StoreChecks(head_slot=Slot(1)),
),
BlockStep(
block=BlockSpec(slot=Slot(2), label="block_2"),
checks=StoreChecks(head_slot=Slot(2)),
),
GossipAggregatedAttestationStep(
attestation=GossipAggregatedAttestationSpec(
validator_ids=[ValidatorIndex(1)],
slot=Slot(3),
target_slot=Slot(2),
target_root_label="block_2",
),
valid=False,
expected_error="Attestation too far in future",
),
]
)
Loading
Loading