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
96 changes: 96 additions & 0 deletions tests/consensus/devnet/fc/test_block_production.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Fork Choice: Block Production"""

import math

import pytest
from consensus_testing import (
AggregatedAttestationCheck,
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down
10 changes: 2 additions & 8 deletions tests/lean_spec/subspecs/api/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@

from __future__ import annotations

import asyncio

import httpx

from lean_spec.subspecs.api import ApiServer, ApiServerConfig
Expand Down Expand Up @@ -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:
Expand All @@ -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."""
Expand Down Expand Up @@ -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()
Loading