Skip to content

Commit 6fd8f6d

Browse files
committed
merge main
2 parents e0006b2 + 98799f0 commit 6fd8f6d

22 files changed

Lines changed: 1300 additions & 45 deletions

File tree

packages/testing/src/consensus_testing/test_fixtures/fork_choice.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
from lean_spec.types import Slot, Uint64, ValidatorIndex
2626

2727
from ..keys import (
28-
LEAN_ENV_TO_SCHEMES,
2928
XmssKeyManager,
3029
)
3130
from ..test_types import (
@@ -330,11 +329,7 @@ def make_fixture(self) -> Self:
330329

331330
# Process the block through Store.
332331
# This validates, applies state transition, and updates the store's head.
333-
store = spec.on_block(
334-
store,
335-
signed_block,
336-
scheme=LEAN_ENV_TO_SCHEMES[self.lean_env],
337-
)
332+
store = spec.on_block(store, signed_block)
338333

339334
case AttestationStep():
340335
# Process a gossip attestation.
@@ -351,7 +346,6 @@ def make_fixture(self) -> Self:
351346
store = spec.on_gossip_attestation(
352347
store,
353348
signed_attestation,
354-
scheme=LEAN_ENV_TO_SCHEMES[self.lean_env],
355349
is_aggregator=step.is_aggregator,
356350
)
357351

packages/testing/src/consensus_testing/test_types/block_spec.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
ValidatorIndices,
3535
)
3636

37-
from ..keys import LEAN_ENV_TO_SCHEMES, XmssKeyManager, create_dummy_signature
37+
from ..keys import XmssKeyManager, create_dummy_signature
3838
from .aggregated_attestation_spec import AggregatedAttestationSpec
3939

4040

@@ -544,7 +544,6 @@ def build_signed_block_with_store(
544544
data=attestation.data,
545545
signature=signature,
546546
),
547-
scheme=LEAN_ENV_TO_SCHEMES[lean_env],
548547
is_aggregator=True,
549548
)
550549

packages/testing/src/framework/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@
44
This module provides base classes and utilities that are common across
55
both consensus and execution layer testing.
66
"""
7+
8+
from .markers import requires_capability
9+
10+
__all__ = ["requires_capability"]
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Helper for the capability-requirement pytest marker."""
2+
3+
import pytest
4+
5+
6+
def requires_capability(*capabilities: type) -> pytest.MarkDecorator:
7+
"""Build a capability-requirement marker over one or more Protocols.
8+
9+
Why a helper is needed at all:
10+
11+
- Pytest treats a single class argument to a marker as the
12+
thing being decorated, not as marker data.
13+
- That makes pytest try to instantiate the class.
14+
- Protocols can't be instantiated, so applying the marker
15+
directly to a Protocol raises TypeError at import.
16+
17+
What this helper does:
18+
19+
- Passes the capability through as marker data instead of as
20+
the decoration target.
21+
- Validates each argument up front, so non-Protocol classes
22+
and Protocols missing the runtime-checkable decorator fail
23+
at import rather than at test collection.
24+
25+
Raises:
26+
TypeError: If any argument is not a runtime-checkable Protocol.
27+
"""
28+
for cap in capabilities:
29+
if not getattr(cap, "_is_runtime_protocol", False):
30+
raise TypeError(
31+
f"requires_capability expects @runtime_checkable Protocols; "
32+
f"got {getattr(cap, '__name__', cap)!r}"
33+
)
34+
return pytest.mark.requires.with_args(*capabilities)

packages/testing/src/framework/pytest_plugins/filler.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Layer-agnostic pytest plugin for generating Ethereum test fixtures."""
22

3+
import functools
34
import importlib
45
import json
56
import shutil
@@ -12,6 +13,13 @@
1213
import pytest
1314

1415

16+
@functools.cache
17+
def _spec_instance_for(fork_class: type) -> Any:
18+
"""Build the active fork's spec instance once and reuse across collection."""
19+
spec_class_method: Any = fork_class.spec_class # ty: ignore[unresolved-attribute]
20+
return spec_class_method()()
21+
22+
1523
class FixtureCollector:
1624
"""Collects generated fixtures and writes them to disk."""
1725

@@ -226,6 +234,11 @@ def pytest_configure(config: pytest.Config) -> None:
226234
"markers",
227235
"valid_at(fork): specifies at which fork a test case is valid",
228236
)
237+
config.addinivalue_line(
238+
"markers",
239+
"requires(*capabilities): only collect when the active fork "
240+
"advertises every listed runtime-checkable Protocol",
241+
)
229242

230243
# Get options
231244
output_dir = Path(config.getoption("--output"))
@@ -313,6 +326,14 @@ def _check_markers_valid_for_fork(
313326
"""Check if test markers indicate validity for the given fork.
314327
315328
Shared logic for both collection-time and parametrization-time fork filtering.
329+
330+
Composition rules:
331+
332+
- Fork-range markers form an intersection across kinds and a union
333+
within a kind.
334+
- The exact-fork marker short-circuits to a single-fork match.
335+
- The capability marker AND-composes on top of either branch — the
336+
active fork must satisfy every listed capability Protocol.
316337
"""
317338
has_valid_from = False
318339
has_valid_until = False
@@ -321,6 +342,7 @@ def _check_markers_valid_for_fork(
321342
valid_from_forks = []
322343
valid_until_forks = []
323344
valid_at_forks = []
345+
required_capabilities: list[type] = []
324346

325347
for marker in markers:
326348
if marker.name == "valid_from":
@@ -341,12 +363,21 @@ def _check_markers_valid_for_fork(
341363
target_fork = get_fork_by_name(fork_name)
342364
if target_fork:
343365
valid_at_forks.append(target_fork)
366+
elif marker.name == "requires":
367+
required_capabilities.extend(marker.args)
368+
369+
def _capability_check() -> bool:
370+
"""Active fork must structurally satisfy every required capability."""
371+
if not required_capabilities:
372+
return True
373+
spec = _spec_instance_for(fork_class)
374+
return all(isinstance(spec, cap) for cap in required_capabilities)
344375

345-
if not (has_valid_from or has_valid_until or has_valid_at):
376+
if not (has_valid_from or has_valid_until or has_valid_at or required_capabilities):
346377
return True
347378

348379
if has_valid_at:
349-
return fork_class in valid_at_forks
380+
return fork_class in valid_at_forks and _capability_check()
350381

351382
from_valid = True
352383
if has_valid_from:
@@ -356,7 +387,7 @@ def _check_markers_valid_for_fork(
356387
if has_valid_until:
357388
until_valid = any(fork_class <= until_fork for until_fork in valid_until_forks)
358389

359-
return from_valid and until_valid
390+
return from_valid and until_valid and _capability_check()
360391

361392

362393
def _is_test_item_valid_for_fork(

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,10 @@ addopts = [
107107
]
108108
markers = [
109109
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
110+
"valid_from: marks tests as valid from a specific fork version",
110111
"valid_until: marks tests as valid until a specific fork version",
112+
"valid_at: marks tests as valid only at a specific fork version",
113+
"requires: marks tests as requiring one or more fork capabilities",
111114
"interop: integration tests for multiple leanSpec nodes",
112115
"num_validators: number of validators for interop test cluster",
113116
]

src/lean_spec/forks/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Multi-fork dispatch layer for leanSpec consensus specification."""
22

3+
from . import capabilities
4+
from .capabilities import SigScheme
35
from .lstar.containers import (
46
AggregatedAttestation,
57
Attestation,
@@ -45,6 +47,7 @@
4547
"ForkRegistry",
4648
"LstarSpec",
4749
"LstarStore",
50+
"SigScheme",
4851
"SignedAggregatedAttestation",
4952
"SignedAttestation",
5053
"SignedBlock",
@@ -54,4 +57,5 @@
5457
"Store",
5558
"Validator",
5659
"Validators",
60+
"capabilities",
5761
]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Optional structural capabilities a fork may advertise."""
2+
3+
from typing import ClassVar, Protocol, runtime_checkable
4+
5+
from lean_spec.subspecs.xmss.interface import GeneralizedXmssScheme
6+
7+
8+
@runtime_checkable
9+
class SigScheme(Protocol):
10+
"""Fork advertising a generalized XMSS signature scheme.
11+
12+
- The runtime check only verifies the attribute is present.
13+
- The static type contract is enforced by the type checker.
14+
"""
15+
16+
sig_scheme: ClassVar[GeneralizedXmssScheme]

src/lean_spec/forks/lstar/spec.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ class LstarSpec(ForkProtocol):
7676

7777
previous: ClassVar[type[ForkProtocol] | None] = None
7878

79+
# Capabilities advertised by this fork.
80+
sig_scheme: ClassVar[GeneralizedXmssScheme] = TARGET_SIGNATURE_SCHEME
81+
7982
state_class: type[State] = State
8083
block_class: type[Block] = Block
8184
block_body_class: type[BlockBody] = BlockBody
@@ -797,7 +800,6 @@ def verify_signatures(
797800
self,
798801
signed_block: SignedBlock,
799802
validators: Validators,
800-
scheme: GeneralizedXmssScheme = TARGET_SIGNATURE_SCHEME,
801803
) -> bool:
802804
"""
803805
Verify the merged Type-2 proof carried by a signed block.
@@ -807,19 +809,18 @@ def verify_signatures(
807809
- Each aggregated attestation in the body to its participants.
808810
- The proposer's signature over the block root.
809811
812+
The signing scheme is read from this fork's capability.
813+
810814
Args:
811815
signed_block: The signed block whose merged proof is checked.
812816
validators: Validator registry providing public keys for verification.
813-
scheme: XMSS signature scheme for verification.
814817
815818
Returns:
816819
True if the merged proof is valid.
817820
818821
Raises:
819822
AssertionError: On any structural or cryptographic mismatch.
820823
"""
821-
_ = scheme
822-
823824
block = signed_block.block
824825
aggregated_attestations = block.body.attestations
825826

@@ -1031,19 +1032,28 @@ def on_gossip_attestation(
10311032
self,
10321033
store: LstarStore,
10331034
signed_attestation: SignedAttestation,
1034-
scheme: GeneralizedXmssScheme = TARGET_SIGNATURE_SCHEME,
10351035
is_aggregator: bool = False,
10361036
) -> LstarStore:
10371037
"""Process a signed attestation received via gossip network.
10381038
10391039
This method:
1040-
1. Verifies the XMSS signature
1040+
1041+
1. Verifies the XMSS signature using this fork's capability
10411042
2. Stores the signature when the node is in aggregator mode
10421043
10431044
Subnet filtering happens at the p2p subscription layer — only
10441045
attestations from subscribed subnets reach this method. No
10451046
additional subnet check is needed here.
10461047
1048+
Args:
1049+
store: The current forkchoice store.
1050+
signed_attestation: The signed attestation to process.
1051+
is_aggregator: True if the node is an aggregator.
1052+
1053+
Returns:
1054+
A new store with the attestation signature recorded when in
1055+
aggregator mode, otherwise the input store unchanged.
1056+
10471057
Raises:
10481058
ValueError: If validator not found in state.
10491059
AssertionError: If signature verification fails.
@@ -1067,7 +1077,7 @@ def on_gossip_attestation(
10671077
)
10681078
public_key = key_state.validators[validator_id].get_attestation_pubkey()
10691079

1070-
assert scheme.verify(
1080+
assert self.sig_scheme.verify(
10711081
public_key, attestation_data.slot, hash_tree_root(attestation_data), signature
10721082
), "Signature verification failed"
10731083

@@ -1164,16 +1174,18 @@ def on_block(
11641174
self,
11651175
store: LstarStore,
11661176
signed_block: SignedBlock,
1167-
scheme: GeneralizedXmssScheme = TARGET_SIGNATURE_SCHEME,
11681177
) -> LstarStore:
11691178
"""Process a new block and update the forkchoice state.
11701179
11711180
This method integrates a block into the forkchoice store by:
1181+
11721182
1. Validating the block's parent exists
11731183
2. Computing the post-state via the state transition function
11741184
3. Processing attestations included in the block body (on-chain)
11751185
4. Updating the forkchoice head
11761186
1187+
Signatures are verified using this fork's capability.
1188+
11771189
Raises:
11781190
AssertionError: If parent block/state not found in store.
11791191
"""
@@ -1200,7 +1212,7 @@ def on_block(
12001212
)
12011213

12021214
# Validate cryptographic signatures
1203-
valid_signatures = self.verify_signatures(signed_block, parent_state.validators, scheme)
1215+
valid_signatures = self.verify_signatures(signed_block, parent_state.validators)
12041216

12051217
# Execute state transition function to compute post-block state
12061218
post_state = self.state_transition(parent_state, block, valid_signatures)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Blocks endpoint handlers."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import logging
7+
8+
from aiohttp import web
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
async def handle_finalized(request: web.Request) -> web.Response:
14+
"""
15+
Handle finalized signed block request.
16+
17+
Returns the SignedBlock matching ``store.latest_finalized.root`` as raw
18+
SSZ bytes (not snappy compressed).
19+
20+
Together with ``/lean/v0/states/finalized`` this lets a checkpoint-syncing
21+
node obtain the ``(state, signed_block)`` pair required by
22+
``Store.create_store`` (which asserts
23+
``anchor_block.state_root == hash_tree_root(state)`` and seeds
24+
``store.blocks[anchor_root] = anchor_block``).
25+
26+
Response: SSZ-encoded SignedBlock (binary, application/octet-stream)
27+
28+
Status Codes:
29+
200 OK: SignedBlock returned successfully.
30+
404 Not Found: Finalized signed block not available on this node
31+
(e.g. server retains only ``Block`` and not ``SignedBlock``).
32+
503 Service Unavailable: Store / signed-block source not initialized.
33+
"""
34+
signed_block_getter = request.app.get("signed_block_getter")
35+
store_getter = request.app.get("store_getter")
36+
store = store_getter() if store_getter else None
37+
38+
if store is None:
39+
raise web.HTTPServiceUnavailable(reason="Store not initialized")
40+
41+
if signed_block_getter is None:
42+
raise web.HTTPServiceUnavailable(reason="Signed block source not configured")
43+
44+
finalized_root = store.latest_finalized.root
45+
signed_block = signed_block_getter(finalized_root)
46+
47+
if signed_block is None:
48+
raise web.HTTPNotFound(reason="Finalized signed block not available")
49+
50+
try:
51+
ssz_bytes = await asyncio.to_thread(signed_block.encode_bytes)
52+
except Exception as e:
53+
logger.error("Failed to encode signed block: %s", e)
54+
raise web.HTTPInternalServerError(reason="Encoding failed") from e
55+
56+
return web.Response(body=ssz_bytes, content_type="application/octet-stream")

0 commit comments

Comments
 (0)