From 441efedb5ead46311f4aa3cfa762fce62c85a293 Mon Sep 17 00:00:00 2001 From: dicethedev Date: Tue, 14 Apr 2026 15:14:33 +0100 Subject: [PATCH 1/4] Adds fork choice tests covering tick interval progression, empty slot time advancement, and interval 0 proposer behavior. Closes #555 #556 #557 --- .codex | 0 .../test_fixtures/fork_choice.py | 21 +- .../test_types/step_types.py | 24 +- .../test_types/store_checks.py | 17 + tests/consensus/devnet/fc/test_tick_system.py | 409 ++++++++++++++++++ 5 files changed, 459 insertions(+), 12 deletions(-) create mode 100644 .codex create mode 100644 tests/consensus/devnet/fc/test_tick_system.py diff --git a/.codex b/.codex new file mode 100644 index 000000000..e69de29bb 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..edd524fb4 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..18a704da3 --- /dev/null +++ b/tests/consensus/devnet/fc/test_tick_system.py @@ -0,0 +1,409 @@ +"""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 + +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 we can observe through the filler. + + Notes: + - `TickStep.time` uses integer unix seconds, so 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, which passes through interval 19 + (slot 3, interval 4) and lands at slot 4 interval 0 + - The filler currently drives ticks with `has_proposal=False`, so interval 0 + only exercises the "no proposer" path here. + """ + 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 + # `accept_new_attestations()` does not run because `has_proposal=False`. + TickStep( + time=12, + checks=StoreChecks( + time=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=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=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=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", + 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), + ), + ], + ), + ), + # `time=16` lands at slot 4 interval 0, which means the store passes + # through slot 3 interval 4 on the way there. Interval 4 always accepts + # new attestations, so the aggregated attestation should migrate from + # "new" to "known" while `safe_target` and head stay on `block_2`. + TickStep( + time=16, + checks=StoreChecks( + time=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", + source_slot=Slot(0), + target_slot=Slot(2), + ), + AttestationCheck( + validator=ValidatorIndex(1), + location="known", + source_slot=Slot(0), + target_slot=Slot(2), + ), + AttestationCheck( + validator=ValidatorIndex(2), + location="known", + source_slot=Slot(0), + 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=10, + head_slot=Slot(1), + head_root_label="block_1", + ), + ), + # 12s -> slot 3 interval 0 -> store time 15 + TickStep( + time=12, + checks=StoreChecks( + time=15, + head_slot=Slot(1), + head_root_label="block_1", + ), + ), + # 16s -> slot 4 interval 0 -> store time 20 + TickStep( + time=16, + checks=StoreChecks( + time=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 the local validator has the + proposal for that slot. + + The fork choice test harness uses local validator 0. With four validators + and round-robin proposal selection: + - slot 3 proposer = validator 3, so validator 0 is not the proposer + - slot 4 proposer = validator 0, so validator 0 is the proposer + """ + 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=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=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=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=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=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=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), + ), + ], + ), + ), + ], + ) + From 1894cc7af61bd5b5e3d5a81b250cd6b471524a9f Mon Sep 17 00:00:00 2001 From: dicethedev Date: Tue, 14 Apr 2026 15:40:28 +0100 Subject: [PATCH 2/4] fix ruff lint formatting issue --- tests/consensus/devnet/fc/test_tick_system.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/consensus/devnet/fc/test_tick_system.py b/tests/consensus/devnet/fc/test_tick_system.py index 18a704da3..4394f529d 100644 --- a/tests/consensus/devnet/fc/test_tick_system.py +++ b/tests/consensus/devnet/fc/test_tick_system.py @@ -406,4 +406,3 @@ def test_tick_interval_0_skips_acceptance_when_not_proposer( ), ], ) - From 85466bb06f30dae4fb1f526cab93b951235e237e Mon Sep 17 00:00:00 2001 From: dicethedev Date: Tue, 14 Apr 2026 15:53:37 +0100 Subject: [PATCH 3/4] Fix Uint64 type errors in StoreChecks test data --- tests/consensus/devnet/fc/test_tick_system.py | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/tests/consensus/devnet/fc/test_tick_system.py b/tests/consensus/devnet/fc/test_tick_system.py index 4394f529d..f828541c0 100644 --- a/tests/consensus/devnet/fc/test_tick_system.py +++ b/tests/consensus/devnet/fc/test_tick_system.py @@ -14,6 +14,7 @@ from lean_spec.subspecs.containers.slot import Slot from lean_spec.subspecs.containers.validator import ValidatorIndex +from lean_spec.types.uint import Uint64 pytestmark = pytest.mark.valid_until("Devnet") @@ -52,7 +53,7 @@ def test_tick_interval_progression_through_full_slot( TickStep( time=12, checks=StoreChecks( - time=15, + time=Uint64(15), head_slot=Slot(2), head_root_label="block_2", ), @@ -62,7 +63,7 @@ def test_tick_interval_progression_through_full_slot( TickStep( time=13, checks=StoreChecks( - time=16, + time=Uint64(16), head_slot=Slot(2), head_root_label="block_2", ), @@ -73,7 +74,7 @@ def test_tick_interval_progression_through_full_slot( TickStep( time=14, checks=StoreChecks( - time=17, + time=Uint64(17), head_slot=Slot(2), head_root_label="block_2", ), @@ -118,7 +119,7 @@ def test_tick_interval_progression_through_full_slot( TickStep( time=15, checks=StoreChecks( - time=18, + time=Uint64(18), head_slot=Slot(2), head_root_label="block_2", safe_target_slot=Slot(2), @@ -152,7 +153,7 @@ def test_tick_interval_progression_through_full_slot( TickStep( time=16, checks=StoreChecks( - time=20, + time=Uint64(20), head_slot=Slot(2), head_root_label="block_2", safe_target_slot=Slot(2), @@ -198,7 +199,7 @@ def test_on_tick_advances_across_multiple_empty_slots( TickStep( time=8, checks=StoreChecks( - time=10, + time=Uint64(10), head_slot=Slot(1), head_root_label="block_1", ), @@ -207,7 +208,7 @@ def test_on_tick_advances_across_multiple_empty_slots( TickStep( time=12, checks=StoreChecks( - time=15, + time=Uint64(15), head_slot=Slot(1), head_root_label="block_1", ), @@ -216,7 +217,7 @@ def test_on_tick_advances_across_multiple_empty_slots( TickStep( time=16, checks=StoreChecks( - time=20, + time=Uint64(20), head_slot=Slot(1), head_root_label="block_1", ), @@ -252,7 +253,7 @@ def test_tick_interval_0_skips_acceptance_when_not_proposer( TickStep( interval=14, checks=StoreChecks( - time=14, + time=Uint64(14), head_slot=Slot(2), head_root_label="block_2", ), @@ -286,7 +287,7 @@ def test_tick_interval_0_skips_acceptance_when_not_proposer( TickStep( interval=15, checks=StoreChecks( - time=15, + time=Uint64(15), head_slot=Slot(2), head_root_label="block_2", attestation_checks=[ @@ -305,7 +306,7 @@ def test_tick_interval_0_skips_acceptance_when_not_proposer( TickStep( interval=19, checks=StoreChecks( - time=19, + time=Uint64(19), head_slot=Slot(2), head_root_label="block_2", ), @@ -342,7 +343,7 @@ def test_tick_interval_0_skips_acceptance_when_not_proposer( interval=20, has_proposal=True, checks=StoreChecks( - time=20, + time=Uint64(20), head_slot=Slot(2), head_root_label="block_2", attestation_checks=[ @@ -361,7 +362,7 @@ def test_tick_interval_0_skips_acceptance_when_not_proposer( TickStep( interval=28, checks=StoreChecks( - time=28, + time=Uint64(28), head_slot=Slot(2), head_root_label="block_2", ), @@ -391,7 +392,7 @@ def test_tick_interval_0_skips_acceptance_when_not_proposer( TickStep( interval=29, checks=StoreChecks( - time=29, + time=Uint64(29), head_slot=Slot(2), head_root_label="block_2", attestation_checks=[ From ff21292278a72856df370b82e4666526ec1a7a0e Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:19:20 +0200 Subject: [PATCH 4/4] test(fc): tick interval progression, empty slot advancement, and proposer-conditional acceptance Add fork choice fillers testing the interval-based tick system. - test_tick_interval_progression_through_full_slot: advance through all 5 intervals of a slot, verify safe_target update at interval 3 and attestation acceptance at interval 4 - test_on_tick_advances_across_multiple_empty_slots: time advances through empty slots without changing the head - test_tick_interval_0_skips_acceptance_when_not_proposer: interval 0 only accepts attestations when has_proposal is True Infrastructure changes: - TickStep gains an interval field (alternative to time) for exact interval targeting, and a has_proposal field for proposer-conditional behavior - StoreChecks gains safe_target_slot and safe_target_root_label assertions Closes #555 Closes #556 Closes #557 Co-Authored-By: dicethedev Co-Authored-By: Claude Opus 4.6 (1M context) --- .codex | 0 .../test_types/step_types.py | 2 +- tests/consensus/devnet/fc/test_tick_system.py | 91 ++++++++----------- 3 files changed, 41 insertions(+), 52 deletions(-) delete mode 100644 .codex diff --git a/.codex b/.codex deleted file mode 100644 index e69de29bb..000000000 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 edd524fb4..53ec10e45 100644 --- a/packages/testing/src/consensus_testing/test_types/step_types.py +++ b/packages/testing/src/consensus_testing/test_types/step_types.py @@ -75,7 +75,7 @@ class TickStep(BaseForkChoiceStep): 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`") + raise ValueError("TickStep requires exactly one of time or interval") return self diff --git a/tests/consensus/devnet/fc/test_tick_system.py b/tests/consensus/devnet/fc/test_tick_system.py index f828541c0..f4d8ba795 100644 --- a/tests/consensus/devnet/fc/test_tick_system.py +++ b/tests/consensus/devnet/fc/test_tick_system.py @@ -14,7 +14,7 @@ from lean_spec.subspecs.containers.slot import Slot from lean_spec.subspecs.containers.validator import ValidatorIndex -from lean_spec.types.uint import Uint64 +from lean_spec.types import Uint64 pytestmark = pytest.mark.valid_until("Devnet") @@ -24,18 +24,26 @@ def test_tick_interval_progression_through_full_slot( ) -> None: """ Advance through slot 3's five-interval tick cycle and verify the - interval-specific store transitions we can observe through the filler. + interval-specific store transitions. - Notes: - - `TickStep.time` uses integer unix seconds, so 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, which passes through interval 19 - (slot 3, interval 4) and lands at slot 4 interval 0 - - The filler currently drives ticks with `has_proposal=False`, so interval 0 - only exercises the "no proposer" path here. + 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=[ @@ -49,7 +57,7 @@ def test_tick_interval_progression_through_full_slot( checks=StoreChecks(head_slot=Slot(2), head_root_label="block_2"), ), # Interval 0 with no proposal: the store reaches slot 3, but - # `accept_new_attestations()` does not run because `has_proposal=False`. + # acceptance does not run because has_proposal is False. TickStep( time=12, checks=StoreChecks( @@ -113,9 +121,9 @@ def test_tick_interval_progression_through_full_slot( ], ), ), - # Interval 3 recomputes `safe_target` using both the "new" and "known" + # 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`. + # in "new" while still being strong enough to move safe_target. TickStep( time=15, checks=StoreChecks( @@ -128,28 +136,14 @@ def test_tick_interval_progression_through_full_slot( 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), ), ], ), ), - # `time=16` lands at slot 4 interval 0, which means the store passes - # through slot 3 interval 4 on the way there. Interval 4 always accepts - # new attestations, so the aggregated attestation should migrate from - # "new" to "known" while `safe_target` and head stay on `block_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( @@ -162,19 +156,6 @@ def test_tick_interval_progression_through_full_slot( AttestationCheck( validator=ValidatorIndex(0), location="known", - source_slot=Slot(0), - target_slot=Slot(2), - ), - AttestationCheck( - validator=ValidatorIndex(1), - location="known", - source_slot=Slot(0), - target_slot=Slot(2), - ), - AttestationCheck( - validator=ValidatorIndex(2), - location="known", - source_slot=Slot(0), target_slot=Slot(2), ), ], @@ -230,13 +211,21 @@ def test_tick_interval_0_skips_acceptance_when_not_proposer( fork_choice_test: ForkChoiceTestFiller, ) -> None: """ - Interval 0 only accepts new attestations when the local validator has the - proposal for that slot. + 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 - The fork choice test harness uses local validator 0. With four validators - and round-robin proposal selection: - - slot 3 proposer = validator 3, so validator 0 is not the proposer - - slot 4 proposer = validator 0, so validator 0 is the proposer + 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=[