Skip to content

Commit 98799f0

Browse files
leolaraclaudetcoratger
authored
feat(forks): add SigScheme capability and @requires marker (Stage 7 of leanEthereum#686) (leanEthereum#715)
* feat(forks): add SigScheme capability and @requires marker (Stage 7 of leanEthereum#686) Introduces the first fork-level capability and a pytest marker to gate tests on capability presence. Capability ---------- - New `SigScheme` runtime-checkable Protocol in `forks/capabilities.py` asserts a `sig_scheme: ClassVar[GeneralizedXmssScheme]` attribute. - `LstarSpec` binds `sig_scheme = TARGET_SIGNATURE_SCHEME` so `isinstance(LstarSpec(), SigScheme)` returns True. - The three spec methods that previously took `scheme=TARGET_SIGNATURE_SCHEME` (`verify_signatures`, `on_gossip_attestation`, `on_block`) drop the parameter and read `self.sig_scheme` directly. The capability becomes the runtime source of truth. Marker ------ - `requires(*capabilities)` pytest marker, registered in `pytest_plugins/filler.py`. Composes (AND) with the existing `valid_from` / `valid_until` / `valid_at` fork-range markers. - `_check_markers_valid_for_fork` instantiates the active spec once and runs `isinstance(spec, capability)` per required capability. - A `requires(...)` helper in `framework.markers` works around pytest's auto-detect-class shortcut (which trips on Protocol args to `@pytest.mark.requires(...)`). Tests ----- - 11 unit tests in `tests/lean_spec/forks/test_capabilities.py` cover the Protocol and the dispatch helper (composition with the fork-range markers, multiple `@requires` markers, error path for non-runtime_checkable Protocol). - One smoke filler test in `tests/consensus/lstar/test_capability_gating.py` exercises the marker through pytest's live collection: one test marked with SigScheme runs, one marked with a synthetic absent capability is deselected. Filler scheme override ---------------------- The three filler call sites that previously passed `scheme=LEAN_ENV_TO_SCHEMES[self.lean_env]` to spec methods (in `test_fixtures/fork_choice.py` and `test_types/block_spec.py`) drop the kwarg. The PR description has the trade-off note and revert path if that override is in fact needed somewhere we missed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(forks): tighten capability marker and apply review feedback - Rename the marker helper to requires_capability and validate runtime-checkable Protocols at call time (fail at import, not at collection) - Cache the fork spec instance in marker dispatch instead of constructing it once per test - Re-export the capabilities namespace from lean_spec.forks so future capabilities don't need new import-site edits - Register valid_from / valid_at / requires markers in pyproject so unit tests can build real pytest Marks under strict-markers - Drop the hand-rolled Mark stand-in in tests; build real Marks via the MarkDecorator path; drop is True / is False on bool predicates - Tighten docstrings per project style (no paragraph blocks, no backtick references, no internal-name references) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(forks): tighten dispatcher-guard test docstring Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Thomas Coratger <60488569+tcoratger@users.noreply.github.com>
1 parent c92e3bf commit 98799f0

11 files changed

Lines changed: 359 additions & 20 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
@@ -29,7 +29,7 @@
2929
from lean_spec.subspecs.xmss.containers import Signature
3030
from lean_spec.types import Bytes32, CamelModel, Slot, ValidatorIndex, ValidatorIndices
3131

32-
from ..keys import LEAN_ENV_TO_SCHEMES, XmssKeyManager, create_dummy_signature
32+
from ..keys import XmssKeyManager, create_dummy_signature
3333
from .aggregated_attestation_spec import AggregatedAttestationSpec
3434

3535

@@ -448,7 +448,6 @@ def build_signed_block_with_store(
448448
data=attestation.data,
449449
signature=signature,
450450
),
451-
scheme=LEAN_ENV_TO_SCHEMES[lean_env],
452451
is_aggregator=True,
453452
)
454453

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
@@ -106,7 +106,10 @@ addopts = [
106106
]
107107
markers = [
108108
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
109+
"valid_from: marks tests as valid from a specific fork version",
109110
"valid_until: marks tests as valid until a specific fork version",
111+
"valid_at: marks tests as valid only at a specific fork version",
112+
"requires: marks tests as requiring one or more fork capabilities",
110113
"interop: integration tests for multiple leanSpec nodes",
111114
"num_validators: number of validators for interop test cluster",
112115
]

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,
@@ -48,6 +50,7 @@
4850
"ForkRegistry",
4951
"LstarSpec",
5052
"LstarStore",
53+
"SigScheme",
5154
"SignedAggregatedAttestation",
5255
"SignedAttestation",
5356
"SignedBlock",
@@ -57,4 +60,5 @@
5760
"Store",
5861
"Validator",
5962
"Validators",
63+
"capabilities",
6064
]
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: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ class LstarSpec(ForkProtocol):
7575

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

78+
# Capabilities advertised by this fork.
79+
sig_scheme: ClassVar[GeneralizedXmssScheme] = TARGET_SIGNATURE_SCHEME
80+
7881
state_class: type[State] = State
7982
block_class: type[Block] = Block
8083
block_body_class: type[BlockBody] = BlockBody
@@ -787,7 +790,6 @@ def verify_signatures(
787790
self,
788791
signed_block: SignedBlock,
789792
validators: Validators,
790-
scheme: GeneralizedXmssScheme = TARGET_SIGNATURE_SCHEME,
791793
) -> bool:
792794
"""
793795
Verify all XMSS signatures in this signed block.
@@ -797,10 +799,11 @@ def verify_signatures(
797799
- Each body attestation is signed by participating validators
798800
- The proposer signed the block root with the proposal key
799801
802+
The signing scheme is read from this fork's capability.
803+
800804
Args:
801805
signed_block: The signed block whose signatures are checked.
802806
validators: Validator registry providing public keys for verification.
803-
scheme: XMSS signature scheme for verification.
804807
805808
Returns:
806809
True if all signatures are valid.
@@ -862,7 +865,7 @@ def verify_signatures(
862865
block_root = hash_tree_root(block)
863866

864867
try:
865-
valid = scheme.verify(
868+
valid = self.sig_scheme.verify(
866869
proposer.get_proposal_pubkey(),
867870
block.slot,
868871
block_root,
@@ -1031,19 +1034,28 @@ def on_gossip_attestation(
10311034
self,
10321035
store: LstarStore,
10331036
signed_attestation: SignedAttestation,
1034-
scheme: GeneralizedXmssScheme = TARGET_SIGNATURE_SCHEME,
10351037
is_aggregator: bool = False,
10361038
) -> LstarStore:
10371039
"""Process a signed attestation received via gossip network.
10381040
10391041
This method:
1040-
1. Verifies the XMSS signature
1042+
1043+
1. Verifies the XMSS signature using this fork's capability
10411044
2. Stores the signature when the node is in aggregator mode
10421045
10431046
Subnet filtering happens at the p2p subscription layer — only
10441047
attestations from subscribed subnets reach this method. No
10451048
additional subnet check is needed here.
10461049
1050+
Args:
1051+
store: The current forkchoice store.
1052+
signed_attestation: The signed attestation to process.
1053+
is_aggregator: True if the node is an aggregator.
1054+
1055+
Returns:
1056+
A new store with the attestation signature recorded when in
1057+
aggregator mode, otherwise the input store unchanged.
1058+
10471059
Raises:
10481060
ValueError: If validator not found in state.
10491061
AssertionError: If signature verification fails.
@@ -1067,7 +1079,7 @@ def on_gossip_attestation(
10671079
)
10681080
public_key = key_state.validators[validator_id].get_attestation_pubkey()
10691081

1070-
assert scheme.verify(
1082+
assert self.sig_scheme.verify(
10711083
public_key, attestation_data.slot, hash_tree_root(attestation_data), signature
10721084
), "Signature verification failed"
10731085

@@ -1160,16 +1172,18 @@ def on_block(
11601172
self,
11611173
store: LstarStore,
11621174
signed_block: SignedBlock,
1163-
scheme: GeneralizedXmssScheme = TARGET_SIGNATURE_SCHEME,
11641175
) -> LstarStore:
11651176
"""Process a new block and update the forkchoice state.
11661177
11671178
This method integrates a block into the forkchoice store by:
1179+
11681180
1. Validating the block's parent exists
11691181
2. Computing the post-state via the state transition function
11701182
3. Processing attestations included in the block body (on-chain)
11711183
4. Updating the forkchoice head
11721184
1185+
Signatures are verified using this fork's capability.
1186+
11731187
Raises:
11741188
AssertionError: If parent block/state not found in store.
11751189
"""
@@ -1196,7 +1210,7 @@ def on_block(
11961210
)
11971211

11981212
# Validate cryptographic signatures
1199-
valid_signatures = self.verify_signatures(signed_block, parent_state.validators, scheme)
1213+
valid_signatures = self.verify_signatures(signed_block, parent_state.validators)
12001214

12011215
# Execute state transition function to compute post-block state
12021216
post_state = self.state_transition(parent_state, block, valid_signatures)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Smoke tests for the capability-requirement marker dispatch."""
2+
3+
from typing import ClassVar, Protocol, runtime_checkable
4+
5+
import pytest
6+
from consensus_testing import StateExpectation, StateTransitionTestFiller, generate_pre_state
7+
from framework import requires_capability
8+
9+
from lean_spec.forks import SigScheme
10+
from lean_spec.types import Slot
11+
12+
pytestmark = pytest.mark.valid_until("Lstar")
13+
14+
15+
@runtime_checkable
16+
class _AbsentCapability(Protocol):
17+
"""A capability no real fork advertises."""
18+
19+
never_an_attribute_on_any_real_fork: ClassVar[object]
20+
21+
22+
@requires_capability(SigScheme)
23+
def test_runs_when_fork_advertises_sigscheme(
24+
state_transition_test: StateTransitionTestFiller,
25+
) -> None:
26+
"""Lstar advertises the signature-scheme capability — this test runs."""
27+
state_transition_test(
28+
pre=generate_pre_state(),
29+
blocks=[],
30+
post=StateExpectation(slot=Slot(0)),
31+
)
32+
33+
34+
@requires_capability(_AbsentCapability)
35+
def test_deselected_when_capability_absent(
36+
state_transition_test: StateTransitionTestFiller,
37+
) -> None:
38+
"""No fork advertises the absent capability — this test must be deselected."""
39+
raise AssertionError("this test was executed — capability-requirement deselection is broken")

0 commit comments

Comments
 (0)