From 08fd174b9b0b08920ff7a4c0e384e46b311b0ce6 Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:23:05 +0200 Subject: [PATCH 1/2] test(fc): produce block enforces MAX_ATTESTATIONS_DATA limit (#565) Adds a fork choice filler that verifies the block builder caps distinct AttestationData entries at MAX_ATTESTATIONS_DATA when more are available. All values (chain length, tick times, expected count) are derived from protocol constants so the test adapts automatically if they change. Also cleans up API server test teardown to use aclose() consistently, removing redundant stop() + sleep() calls across all test classes. Co-Authored-By: Ayush Rangrej Co-Authored-By: Claude Sonnet 4.6 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../devnet/fc/test_block_production.py | 108 ++++++++++++++++++ tests/lean_spec/subspecs/api/test_server.py | 10 +- 2 files changed, 110 insertions(+), 8 deletions(-) diff --git a/tests/consensus/devnet/fc/test_block_production.py b/tests/consensus/devnet/fc/test_block_production.py index e53633644..06ad17e73 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,106 @@ 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) + ] + + # The builder sorts by target.slot ascending, so the first + # MAX_ATTESTATIONS_DATA entries are included and the last is excluded. + expected_attestations = [ + AggregatedAttestationCheck( + participants={0, 1, 2}, + attestation_slot=Slot(block_production_slot), + target_slot=Slot(n), + ) + for n in range(1, num_target_blocks) + ] + + 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, + block_attestations=expected_attestations, + ), + ), + ] + ) + + 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() From e6257006edbdbaa721860ca3510fa629b8a067ff Mon Sep 17 00:00:00 2001 From: Ayush Rangrej Date: Wed, 15 Apr 2026 09:56:36 -0700 Subject: [PATCH 2/2] fix(fc): remove block_attestations assertion with non-unique participants --- tests/consensus/devnet/fc/test_block_production.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/consensus/devnet/fc/test_block_production.py b/tests/consensus/devnet/fc/test_block_production.py index 06ad17e73..7db3e9afd 100644 --- a/tests/consensus/devnet/fc/test_block_production.py +++ b/tests/consensus/devnet/fc/test_block_production.py @@ -311,17 +311,6 @@ def test_produce_block_enforces_max_attestations_data_limit( for n in range(1, num_target_blocks + 1) ] - # The builder sorts by target.slot ascending, so the first - # MAX_ATTESTATIONS_DATA entries are included and the last is excluded. - expected_attestations = [ - AggregatedAttestationCheck( - participants={0, 1, 2}, - attestation_slot=Slot(block_production_slot), - target_slot=Slot(n), - ) - for n in range(1, num_target_blocks) - ] - fork_choice_test( steps=[ *chain_steps, @@ -336,7 +325,6 @@ def test_produce_block_enforces_max_attestations_data_limit( checks=StoreChecks( head_slot=Slot(block_production_slot), block_attestation_count=limit, - block_attestations=expected_attestations, ), ), ]