diff --git a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py index c51fdaed8..6204f74f7 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py +++ b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py @@ -227,14 +227,21 @@ def make_fixture(self) -> Self: # Time advancement may trigger slot boundaries. # At slot boundaries, pending attestations may become active. # Always act as aggregator to ensure gossip signatures are aggregated - # - # TickStep.time is a Unix timestamp in seconds. - # Convert to intervals since genesis for the store. - target_interval = Interval.from_unix_time( - Uint64(step.time), store.config.genesis_time - ) + if step.interval is not None: + # Tests that care about exact interval semantics can + # target the store's internal interval clock directly. + target_interval = Interval(step.interval) + else: + assert step.time is not None + # TickStep.time is a Unix timestamp in seconds. + # Convert to intervals since genesis for the store. + target_interval = Interval.from_unix_time( + Uint64(step.time), store.config.genesis_time + ) store, _ = store.on_tick( - target_interval, has_proposal=False, is_aggregator=True + target_interval, + has_proposal=step.has_proposal, + is_aggregator=True, ) case BlockStep(): diff --git a/packages/testing/src/consensus_testing/test_types/step_types.py b/packages/testing/src/consensus_testing/test_types/step_types.py index 6b882ce68..53ec10e45 100644 --- a/packages/testing/src/consensus_testing/test_types/step_types.py +++ b/packages/testing/src/consensus_testing/test_types/step_types.py @@ -2,7 +2,7 @@ from typing import Annotated, Any, Literal, Union -from pydantic import ConfigDict, Field, PrivateAttr, field_serializer +from pydantic import ConfigDict, Field, PrivateAttr, field_serializer, model_validator from lean_spec.subspecs.containers.attestation import ( SignedAggregatedAttestation, @@ -54,15 +54,29 @@ class TickStep(BaseForkChoiceStep): """ Time advancement step. - Advances the fork choice store time to a specific unix timestamp. - This triggers interval-based actions like attestation processing. + Advances the fork choice store time to a specific unix timestamp or + exact interval count. This triggers interval-based actions like + attestation processing. """ step_type: Literal["tick"] = "tick" """Discriminator field for serialization.""" - time: int - """Time to advance to (unix timestamp).""" + time: int | None = None + """Optional unix timestamp to advance to.""" + + interval: int | None = None + """Optional exact interval count to advance to.""" + + has_proposal: bool = False + """Whether interval 0 of the target slot should see a proposal.""" + + @model_validator(mode="after") + def validate_target(self) -> "TickStep": + """Require exactly one time target representation.""" + if (self.time is None) == (self.interval is None): + raise ValueError("TickStep requires exactly one of time or interval") + return self class BlockStep(BaseForkChoiceStep): diff --git a/packages/testing/src/consensus_testing/test_types/store_checks.py b/packages/testing/src/consensus_testing/test_types/store_checks.py index 506e1e50b..17f08a454 100644 --- a/packages/testing/src/consensus_testing/test_types/store_checks.py +++ b/packages/testing/src/consensus_testing/test_types/store_checks.py @@ -168,6 +168,17 @@ class StoreChecks(CamelModel): safe_target: Bytes32 | None = None """Expected safe target root.""" + safe_target_slot: Slot | None = None + """Expected safe target block slot.""" + + safe_target_root_label: str | None = None + """ + Expected safe target root by label reference. + + Alternative to safe_target that uses the block label system. + The framework resolves this label to the actual block root. + """ + attestation_target_slot: Slot | None = None """ Expected attestation target checkpoint slot. @@ -286,6 +297,8 @@ def _resolve(label: str) -> Bytes32: _check("latest_finalized.root", store.latest_finalized.root, self.latest_finalized_root) if "safe_target" in fields: _check("safe_target", store.safe_target, self.safe_target) + if "safe_target_slot" in fields: + _check("safe_target.slot", store.blocks[store.safe_target].slot, self.safe_target_slot) # Label-based root checks (resolve label -> root, then compare) if "head_root_label" in fields: @@ -309,6 +322,10 @@ def _resolve(label: str) -> Bytes32: assert self.latest_finalized_root_label is not None expected = _resolve(self.latest_finalized_root_label) _check("latest_finalized.root", store.latest_finalized.root, expected) + if "safe_target_root_label" in fields: + assert self.safe_target_root_label is not None + expected = _resolve(self.safe_target_root_label) + _check("safe_target", store.safe_target, expected) # Attestation target checkpoint (slot + root consistency) if "attestation_target_slot" in fields: diff --git a/tests/consensus/devnet/fc/test_tick_system.py b/tests/consensus/devnet/fc/test_tick_system.py new file mode 100644 index 000000000..f4d8ba795 --- /dev/null +++ b/tests/consensus/devnet/fc/test_tick_system.py @@ -0,0 +1,398 @@ +"""Fork choice tick interval progression tests.""" + +import pytest +from consensus_testing import ( + AttestationCheck, + BlockSpec, + BlockStep, + ForkChoiceTestFiller, + GossipAggregatedAttestationSpec, + GossipAggregatedAttestationStep, + StoreChecks, + TickStep, +) + +from lean_spec.subspecs.containers.slot import Slot +from lean_spec.subspecs.containers.validator import ValidatorIndex +from lean_spec.types import Uint64 + +pytestmark = pytest.mark.valid_until("Devnet") + + +def test_tick_interval_progression_through_full_slot( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + Advance through slot 3's five-interval tick cycle and verify the + interval-specific store transitions. + + Scenario + -------- + TickStep.time uses integer unix seconds. With genesis_time=0 and + MILLISECONDS_PER_INTERVAL=800, slot 3 intervals map to: + + - 12s -> interval 15 (slot 3, interval 0) + - 13s -> interval 16 (slot 3, interval 1) + - 14s -> interval 17 (slot 3, interval 2) + - 15s -> interval 18 (slot 3, interval 3) + - 16s -> interval 20 (passes through interval 19 = slot 3 interval 4, + then lands at slot 4 interval 0) + + Expected Behavior + ----------------- + 1. Intervals 0-2: no observable store mutation (no proposal, no pending data) + 2. After gossip at interval 2: attestation lands in "new" pool + 3. Interval 3: safe_target recomputed using "new" pool + 4. Interval 4: attestations migrate from "new" to "known" + """ + fork_choice_test( + steps=[ + # Build a short chain so slot 3 can be reached. + BlockStep( + block=BlockSpec(slot=Slot(1), label="block_1"), + checks=StoreChecks(head_slot=Slot(1), head_root_label="block_1"), + ), + BlockStep( + block=BlockSpec(slot=Slot(2), label="block_2"), + checks=StoreChecks(head_slot=Slot(2), head_root_label="block_2"), + ), + # Interval 0 with no proposal: the store reaches slot 3, but + # acceptance does not run because has_proposal is False. + TickStep( + time=12, + checks=StoreChecks( + time=Uint64(15), + head_slot=Slot(2), + head_root_label="block_2", + ), + ), + # Interval 1 is the vote propagation window, so there is no direct + # store mutation to assert beyond time/head stability. + TickStep( + time=13, + checks=StoreChecks( + time=Uint64(16), + head_slot=Slot(2), + head_root_label="block_2", + ), + ), + # Interval 2 is the aggregation window. We tick through it first, then + # inject an already aggregated gossip attestation so it remains in the + # "new" pool for the interval-3 and interval-4 checks below. + TickStep( + time=14, + checks=StoreChecks( + time=Uint64(17), + head_slot=Slot(2), + head_root_label="block_2", + ), + ), + GossipAggregatedAttestationStep( + attestation=GossipAggregatedAttestationSpec( + validator_ids=[ + ValidatorIndex(0), + ValidatorIndex(1), + ValidatorIndex(2), + ], + slot=Slot(3), + target_slot=Slot(2), + target_root_label="block_2", + ), + checks=StoreChecks( + attestation_checks=[ + AttestationCheck( + validator=ValidatorIndex(0), + location="new", + source_slot=Slot(0), + target_slot=Slot(2), + ), + AttestationCheck( + validator=ValidatorIndex(1), + location="new", + source_slot=Slot(0), + target_slot=Slot(2), + ), + AttestationCheck( + validator=ValidatorIndex(2), + location="new", + source_slot=Slot(0), + target_slot=Slot(2), + ), + ], + ), + ), + # Interval 3 recomputes safe_target using both the "new" and "known" + # attestation pools. The attestation is still unaccepted, so it remains + # in "new" while still being strong enough to move safe_target. + TickStep( + time=15, + checks=StoreChecks( + time=Uint64(18), + head_slot=Slot(2), + head_root_label="block_2", + safe_target_slot=Slot(2), + safe_target_root_label="block_2", + attestation_checks=[ + AttestationCheck( + validator=ValidatorIndex(0), + location="new", + target_slot=Slot(2), + ), + ], + ), + ), + # time=16 lands at slot 4 interval 0, passing through slot 3 interval 4 + # on the way. Interval 4 always accepts new attestations, so the + # attestation migrates from "new" to "known". + TickStep( + time=16, + checks=StoreChecks( + time=Uint64(20), + head_slot=Slot(2), + head_root_label="block_2", + safe_target_slot=Slot(2), + safe_target_root_label="block_2", + attestation_checks=[ + AttestationCheck( + validator=ValidatorIndex(0), + location="known", + target_slot=Slot(2), + ), + ], + ), + ), + ], + ) + + +def test_on_tick_advances_across_multiple_empty_slots( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """Time advances through multiple empty slots without changing the head.""" + fork_choice_test( + steps=[ + BlockStep( + block=BlockSpec(slot=Slot(1), label="block_1"), + checks=StoreChecks(head_slot=Slot(1), head_root_label="block_1"), + ), + # Slot boundaries are the cleanest integer-second checkpoints: + # 8s -> slot 2 interval 0 -> store time 10 + TickStep( + time=8, + checks=StoreChecks( + time=Uint64(10), + head_slot=Slot(1), + head_root_label="block_1", + ), + ), + # 12s -> slot 3 interval 0 -> store time 15 + TickStep( + time=12, + checks=StoreChecks( + time=Uint64(15), + head_slot=Slot(1), + head_root_label="block_1", + ), + ), + # 16s -> slot 4 interval 0 -> store time 20 + TickStep( + time=16, + checks=StoreChecks( + time=Uint64(20), + head_slot=Slot(1), + head_root_label="block_1", + ), + ), + ], + ) + + +def test_tick_interval_0_skips_acceptance_when_not_proposer( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + Interval 0 only accepts new attestations when a proposal exists. + + Scenario + -------- + 1. Tick to slot 3 interval 0 without a proposal: attestations stay in "new" + 2. Tick to slot 4 interval 0 with has_proposal=True: attestations migrate + to "known" immediately + 3. Tick to slot 5 interval 4 (unconditional acceptance): fresh attestations + also migrate without a proposal + + Expected Behavior + ----------------- + 1. Non-proposer interval 0: no acceptance + 2. Proposer interval 0: early acceptance + 3. Interval 4: always accepts regardless of proposer status + """ + fork_choice_test( + steps=[ + BlockStep( + block=BlockSpec(slot=Slot(1), label="block_1"), + checks=StoreChecks(head_slot=Slot(1), head_root_label="block_1"), + ), + BlockStep( + block=BlockSpec(slot=Slot(2), label="block_2"), + checks=StoreChecks(head_slot=Slot(2), head_root_label="block_2"), + ), + # Reach the interval immediately before slot 3 interval 0 so a fresh + # attestation can remain pending into the non-proposer check. + TickStep( + interval=14, + checks=StoreChecks( + time=Uint64(14), + head_slot=Slot(2), + head_root_label="block_2", + ), + ), + # Start with a pending aggregated attestation for slot 3. + GossipAggregatedAttestationStep( + attestation=GossipAggregatedAttestationSpec( + validator_ids=[ + ValidatorIndex(0), + ValidatorIndex(1), + ValidatorIndex(2), + ], + slot=Slot(3), + target_slot=Slot(2), + target_root_label="block_2", + ), + checks=StoreChecks( + attestation_checks=[ + AttestationCheck( + validator=ValidatorIndex(0), + location="new", + source_slot=Slot(0), + target_slot=Slot(2), + ), + ], + ), + ), + # Exact interval 15 is slot 3 interval 0. Validator 0 is not the + # proposer for slot 3, so interval 0 must leave the attestation + # in the "new" pool. + TickStep( + interval=15, + checks=StoreChecks( + time=Uint64(15), + head_slot=Slot(2), + head_root_label="block_2", + attestation_checks=[ + AttestationCheck( + validator=ValidatorIndex(0), + location="new", + source_slot=Slot(0), + target_slot=Slot(2), + ), + ], + ), + ), + # Move to the interval immediately before slot 4 interval 0. We do + # not assert on the old pending attestation here because interval 2's + # aggregation path rewrites the "new" pool before slot 3 interval 4. + TickStep( + interval=19, + checks=StoreChecks( + time=Uint64(19), + head_slot=Slot(2), + head_root_label="block_2", + ), + ), + # Add a fresh pending attestation right before slot 4 interval 0. + # At store time 19, current slot is still 3, so a slot-4 attestation + # is within the allowed +1 future-slot margin. + GossipAggregatedAttestationStep( + attestation=GossipAggregatedAttestationSpec( + validator_ids=[ + ValidatorIndex(0), + ValidatorIndex(1), + ValidatorIndex(2), + ], + slot=Slot(4), + target_slot=Slot(2), + target_root_label="block_2", + ), + checks=StoreChecks( + attestation_checks=[ + AttestationCheck( + validator=ValidatorIndex(1), + location="new", + source_slot=Slot(0), + target_slot=Slot(2), + ), + ], + ), + ), + # Exact interval 20 is slot 4 interval 0. Validator 0 is the proposer + # for slot 4, so interval 0 should accept the pending attestation + # immediately instead of waiting until interval 4. + TickStep( + interval=20, + has_proposal=True, + checks=StoreChecks( + time=Uint64(20), + head_slot=Slot(2), + head_root_label="block_2", + attestation_checks=[ + AttestationCheck( + validator=ValidatorIndex(1), + location="known", + source_slot=Slot(0), + target_slot=Slot(2), + ), + ], + ), + ), + # Reach slot 5 interval 3, inject a fresh attestation after the + # aggregation interval, then verify interval 4 accepts it even + # without a proposal. + TickStep( + interval=28, + checks=StoreChecks( + time=Uint64(28), + head_slot=Slot(2), + head_root_label="block_2", + ), + ), + GossipAggregatedAttestationStep( + attestation=GossipAggregatedAttestationSpec( + validator_ids=[ + ValidatorIndex(1), + ValidatorIndex(2), + ValidatorIndex(3), + ], + slot=Slot(5), + target_slot=Slot(2), + target_root_label="block_2", + ), + checks=StoreChecks( + attestation_checks=[ + AttestationCheck( + validator=ValidatorIndex(3), + location="new", + source_slot=Slot(0), + target_slot=Slot(2), + ), + ], + ), + ), + TickStep( + interval=29, + checks=StoreChecks( + time=Uint64(29), + head_slot=Slot(2), + head_root_label="block_2", + attestation_checks=[ + AttestationCheck( + validator=ValidatorIndex(3), + location="known", + source_slot=Slot(0), + target_slot=Slot(2), + ), + ], + ), + ), + ], + )