diff --git a/tests/consensus/devnet/fc/test_block_production.py b/tests/consensus/devnet/fc/test_block_production.py index e53633644..7db3e9afd 100644 --- a/tests/consensus/devnet/fc/test_block_production.py +++ b/tests/consensus/devnet/fc/test_block_production.py @@ -1,5 +1,7 @@ """Fork Choice: Block Production""" +import math + import pytest from consensus_testing import ( AggregatedAttestationCheck, @@ -14,6 +16,12 @@ TickStep, ) +from lean_spec.subspecs.chain.config import ( + INTERVALS_PER_SLOT, + MAX_ATTESTATIONS_DATA, + MILLISECONDS_PER_INTERVAL, + SECONDS_PER_SLOT, +) from lean_spec.subspecs.containers.slot import Slot from lean_spec.subspecs.containers.validator import ValidatorIndex @@ -235,6 +243,94 @@ def test_block_builder_fixed_point_advances_justification( ) +def test_produce_block_enforces_max_attestations_data_limit( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + Block production caps attestation data entries at MAX_ATTESTATIONS_DATA. + + Scenario + -------- + Linear chain through MAX_ATTESTATIONS_DATA + 1 blocks. After building + the chain, the same number of aggregated attestations are gossiped — + each targeting a different block — producing one more distinct + AttestationData entry than the limit allows. + + Timing + ------ + Attestations are gossiped after the aggregate interval of the last + chain slot (so the aggregate step is a no-op on an empty pool), then + a tick to the next slot start migrates them from "new" to "known". + + Block builder behavior + ---------------------- + The builder sorts entries by target.slot and processes them in order. + After selecting MAX_ATTESTATIONS_DATA entries it breaks, excluding the + entry with the highest target slot. + + Expected post-state + ------------------- + The produced block contains exactly MAX_ATTESTATIONS_DATA attestations. + """ + limit = int(MAX_ATTESTATIONS_DATA) + num_target_blocks = limit + 1 + block_production_slot = num_target_blocks + 1 + validators = [ValidatorIndex(0), ValidatorIndex(1), ValidatorIndex(2)] + + # Aggregate fires at interval 2 of the last chain slot. + # With an empty pool this is a no-op, so no payloads are lost. + # Compute the minimum integer second that reaches this interval. + aggregate_interval = num_target_blocks * int(INTERVALS_PER_SLOT) + 2 + aggregate_time = math.ceil(aggregate_interval * int(MILLISECONDS_PER_INTERVAL) / 1000) + # Next slot start migrates gossip payloads from "new" to "known". + next_slot_time = block_production_slot * int(SECONDS_PER_SLOT) + + # Build a linear chain. Each block is labeled so attestations can + # reference it as a target. + chain_steps: list[BlockStep] = [ + BlockStep( + block=BlockSpec(slot=Slot(n), label=f"block_{n}"), + checks=(StoreChecks(head_slot=Slot(n)) if n == 1 or n == num_target_blocks else None), + ) + for n in range(1, num_target_blocks + 1) + ] + + # One gossip attestation per target block. + # Each has a different target checkpoint → num_target_blocks distinct + # AttestationData entries. + # Source auto-resolves to the genesis justified checkpoint. + attestation_steps: list[GossipAggregatedAttestationStep] = [ + GossipAggregatedAttestationStep( + attestation=GossipAggregatedAttestationSpec( + validator_ids=validators, + slot=Slot(block_production_slot), + target_slot=Slot(n), + target_root_label=f"block_{n}", + ), + ) + for n in range(1, num_target_blocks + 1) + ] + + fork_choice_test( + steps=[ + *chain_steps, + TickStep(time=aggregate_time), + *attestation_steps, + TickStep(time=next_slot_time), + BlockStep( + block=BlockSpec( + slot=Slot(block_production_slot), + label=f"block_{block_production_slot}", + ), + checks=StoreChecks( + head_slot=Slot(block_production_slot), + block_attestation_count=limit, + ), + ), + ] + ) + + def test_produce_block_includes_pending_attestations( fork_choice_test: ForkChoiceTestFiller, ) -> None: diff --git a/tests/lean_spec/subspecs/api/test_server.py b/tests/lean_spec/subspecs/api/test_server.py index 761f5562e..5b53beea7 100644 --- a/tests/lean_spec/subspecs/api/test_server.py +++ b/tests/lean_spec/subspecs/api/test_server.py @@ -11,8 +11,6 @@ from __future__ import annotations -import asyncio - import httpx from lean_spec.subspecs.api import ApiServer, ApiServerConfig @@ -100,8 +98,6 @@ async def test_returns_503_when_store_not_initialized(self) -> None: finally: await server.aclose() - server.stop() - await asyncio.sleep(0.1) class TestForkChoiceEndpoint: @@ -121,8 +117,7 @@ async def test_returns_503_when_store_not_initialized(self) -> None: assert response.status_code == 503 finally: - server.stop() - await asyncio.sleep(0.1) + await server.aclose() async def test_returns_200_with_initialized_store(self, base_store: Store) -> None: """Endpoint returns 200 with fork choice tree when store is initialized.""" @@ -169,5 +164,4 @@ async def test_returns_200_with_initialized_store(self, base_store: Store) -> No assert node["weight"] == 0 finally: - server.stop() - await asyncio.sleep(0.1) + await server.aclose()