From b73c64197ff41f2dd85230f525c9eeaa2589d1e0 Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Fri, 29 May 2026 14:45:08 +0200 Subject: [PATCH 1/2] refactor(forks/lstar): move multi-signature types out of crypto layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crypto subspec imported AggregationBits, Slot, ValidatorIndex, and ValidatorIndices from the consensus layer to define TypeOneMultiSignature and TypeTwoMultiSignature. That layering inversion forced three mid-file deferred imports with # noqa: E402 in lstar/containers.py and made the crypto layer reach into consensus for types it should never know about. Move the multi-signature classes (plus AggregationError) into the consensus layer, where they belong as domain-typed wrappers around the crypto byte-level primitives. The crypto layer keeps only the Rust prover bindings. Slot moves into its own small module so the crypto layer can name a slot without pulling the full consensus container module. The crypto API still uses Slot, not Uint64 — the layering fix preserves semantics. Net result: - crypto/xmss/aggregation.py shrinks from 348 lines to 38; no consensus imports remain. - crypto/xmss/{containers,interface}.py import Slot from lstar/slot.py. - lstar/containers.py promotes all three previously deferred imports (HISTORICAL_ROOTS_LIMIT, multi-sig types, PublicKey/Signature) to the top of the file. The # noqa: E402 block is gone. - 17 importers updated to fetch TypeOneMultiSignature etc. from the consensus layer. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../testing/src/consensus_testing/keys.py | 7 +- .../test_fixtures/state_transition.py | 2 +- .../test_types/aggregated_attestation_spec.py | 2 +- .../test_types/block_spec.py | 6 +- .../gossip_aggregated_attestation_spec.py | 2 +- src/lean_spec/node/sync/service.py | 10 +- src/lean_spec/node/validator/service.py | 2 +- src/lean_spec/spec/crypto/xmss/aggregation.py | 337 +-------------- src/lean_spec/spec/crypto/xmss/containers.py | 2 +- src/lean_spec/spec/crypto/xmss/interface.py | 2 +- .../spec/forks/lstar/aggregation_select.py | 3 +- src/lean_spec/spec/forks/lstar/containers.py | 396 ++++++++++++++---- src/lean_spec/spec/forks/lstar/slot.py | 79 ++++ src/lean_spec/spec/forks/lstar/spec.py | 8 +- src/lean_spec/spec/forks/lstar/store.py | 2 +- .../lstar/ssz/test_consensus_containers.py | 2 +- .../lstar/ssz/test_xmss_containers.py | 8 +- tests/lean_spec/helpers/builders.py | 3 +- .../lean_spec/node/validator/test_service.py | 3 +- .../spec/crypto/xmss/test_aggregation.py | 4 +- .../forkchoice/test_attestation_target.py | 3 +- .../forkchoice/test_compute_block_weights.py | 3 +- .../forkchoice/test_store_attestations.py | 2 +- .../lstar/forkchoice/test_store_pruning.py | 2 +- .../forks/lstar/forkchoice/test_validator.py | 2 +- 25 files changed, 455 insertions(+), 437 deletions(-) create mode 100644 src/lean_spec/spec/forks/lstar/slot.py diff --git a/packages/testing/src/consensus_testing/keys.py b/packages/testing/src/consensus_testing/keys.py index 522e472a..8caa8c75 100755 --- a/packages/testing/src/consensus_testing/keys.py +++ b/packages/testing/src/consensus_testing/keys.py @@ -43,7 +43,6 @@ from lean_spec.config import LEAN_ENV from lean_spec.spec.crypto.koalabear import Fp from lean_spec.spec.crypto.merkleization import hash_tree_root -from lean_spec.spec.crypto.xmss.aggregation import TypeOneMultiSignature from lean_spec.spec.crypto.xmss.constants import TARGET_CONFIG from lean_spec.spec.crypto.xmss.containers import ( PublicKey, @@ -63,7 +62,11 @@ Randomness, ) from lean_spec.spec.forks import Slot, ValidatorIndex -from lean_spec.spec.forks.lstar.containers import AggregatedAttestations, AttestationData +from lean_spec.spec.forks.lstar.containers import ( + AggregatedAttestations, + AttestationData, + TypeOneMultiSignature, +) from lean_spec.spec.ssz import Bytes32, Uint64 KeyRole = Literal["attestation", "proposal"] diff --git a/packages/testing/src/consensus_testing/test_fixtures/state_transition.py b/packages/testing/src/consensus_testing/test_fixtures/state_transition.py index e310b327..fbac6655 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/state_transition.py +++ b/packages/testing/src/consensus_testing/test_fixtures/state_transition.py @@ -5,7 +5,6 @@ from pydantic import ConfigDict, PrivateAttr, field_serializer from lean_spec.spec.crypto.merkleization import hash_tree_root -from lean_spec.spec.crypto.xmss.aggregation import TypeOneMultiSignature from lean_spec.spec.forks import ValidatorIndices from lean_spec.spec.forks.lstar.containers import ( AggregatedAttestation, @@ -14,6 +13,7 @@ Block, BlockBody, State, + TypeOneMultiSignature, ) from lean_spec.spec.forks.lstar.spec import LstarSpec from lean_spec.spec.ssz import Bytes32 diff --git a/packages/testing/src/consensus_testing/test_types/aggregated_attestation_spec.py b/packages/testing/src/consensus_testing/test_types/aggregated_attestation_spec.py index a321efd4..c6206360 100644 --- a/packages/testing/src/consensus_testing/test_types/aggregated_attestation_spec.py +++ b/packages/testing/src/consensus_testing/test_types/aggregated_attestation_spec.py @@ -3,7 +3,6 @@ from __future__ import annotations from lean_spec.base import CamelModel -from lean_spec.spec.crypto.xmss.aggregation import TypeOneMultiSignature from lean_spec.spec.forks import Checkpoint, Slot, ValidatorIndex, ValidatorIndices from lean_spec.spec.forks.lstar.containers import ( AggregatedAttestation, @@ -11,6 +10,7 @@ AttestationData, Block, State, + TypeOneMultiSignature, ) from lean_spec.spec.ssz import ByteList512KiB, Bytes32 diff --git a/packages/testing/src/consensus_testing/test_types/block_spec.py b/packages/testing/src/consensus_testing/test_types/block_spec.py index eaa373cc..fbfa4b1b 100644 --- a/packages/testing/src/consensus_testing/test_types/block_spec.py +++ b/packages/testing/src/consensus_testing/test_types/block_spec.py @@ -8,10 +8,6 @@ from lean_spec.base import CamelModel from lean_spec.node.chain.clock import Interval from lean_spec.spec.crypto.merkleization import hash_tree_root -from lean_spec.spec.crypto.xmss.aggregation import ( - TypeOneMultiSignature, - TypeTwoMultiSignature, -) from lean_spec.spec.crypto.xmss.containers import Signature from lean_spec.spec.forks import Slot, ValidatorIndex, ValidatorIndices from lean_spec.spec.forks.lstar.containers import ( @@ -24,6 +20,8 @@ SignedAttestation, SignedBlock, State, + TypeOneMultiSignature, + TypeTwoMultiSignature, ) from lean_spec.spec.forks.lstar.spec import LstarSpec from lean_spec.spec.forks.lstar.store import Store diff --git a/packages/testing/src/consensus_testing/test_types/gossip_aggregated_attestation_spec.py b/packages/testing/src/consensus_testing/test_types/gossip_aggregated_attestation_spec.py index 328badd0..3547cd1e 100644 --- a/packages/testing/src/consensus_testing/test_types/gossip_aggregated_attestation_spec.py +++ b/packages/testing/src/consensus_testing/test_types/gossip_aggregated_attestation_spec.py @@ -3,13 +3,13 @@ from __future__ import annotations from lean_spec.base import CamelModel -from lean_spec.spec.crypto.xmss.aggregation import TypeOneMultiSignature from lean_spec.spec.forks import Checkpoint, Slot, ValidatorIndex, ValidatorIndices from lean_spec.spec.forks.lstar.containers import ( AttestationData, Block, SignedAggregatedAttestation, State, + TypeOneMultiSignature, ) from lean_spec.spec.ssz import ByteList512KiB, Bytes32 diff --git a/src/lean_spec/node/sync/service.py b/src/lean_spec/node/sync/service.py index eab3725a..e815bf12 100644 --- a/src/lean_spec/node/sync/service.py +++ b/src/lean_spec/node/sync/service.py @@ -17,11 +17,6 @@ from lean_spec.node.networking.transport.peer_id import PeerId from lean_spec.node.storage import Database from lean_spec.spec.crypto.merkleization import hash_tree_root -from lean_spec.spec.crypto.xmss.aggregation import ( - AggregationError, - TypeOneMultiSignature, - TypeTwoMultiSignature, -) from lean_spec.spec.crypto.xmss.containers import PublicKey from lean_spec.spec.forks import ( AttestationData, @@ -33,6 +28,11 @@ Slot, Store, ) +from lean_spec.spec.forks.lstar.containers import ( + AggregationError, + TypeOneMultiSignature, + TypeTwoMultiSignature, +) from lean_spec.spec.ssz import Bytes32 from lean_spec.spec.ssz.exceptions import SSZError diff --git a/src/lean_spec/node/validator/service.py b/src/lean_spec/node/validator/service.py index b142eb47..db87ca98 100644 --- a/src/lean_spec/node/validator/service.py +++ b/src/lean_spec/node/validator/service.py @@ -41,7 +41,6 @@ from lean_spec.node.sync import SyncService from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.crypto.xmss import TARGET_SIGNATURE_SCHEME -from lean_spec.spec.crypto.xmss.aggregation import TypeOneMultiSignature, TypeTwoMultiSignature from lean_spec.spec.crypto.xmss.containers import PublicKey, Signature from lean_spec.spec.forks import ( AttestationData, @@ -52,6 +51,7 @@ Slot, ValidatorIndex, ) +from lean_spec.spec.forks.lstar.containers import TypeOneMultiSignature, TypeTwoMultiSignature from lean_spec.spec.ssz import ByteList512KiB, Bytes32, Uint64 from .constants import HYSTERESIS_BAND, NETWORK_STALL_THRESHOLD, SYNC_LAG_THRESHOLD diff --git a/src/lean_spec/spec/crypto/xmss/aggregation.py b/src/lean_spec/spec/crypto/xmss/aggregation.py index 7306c4f3..69f93ee7 100644 --- a/src/lean_spec/spec/crypto/xmss/aggregation.py +++ b/src/lean_spec/spec/crypto/xmss/aggregation.py @@ -1,11 +1,7 @@ -"""Multi-signature aggregation over Generalized XMSS. +"""Byte-level prover binding for multi-signature aggregation. -Two proof shapes: - -- Type-1: many validators on a single message (one attestation, or one block root). -- Type-2: a merge of several Type-1 proofs over distinct messages. - -The Rust binding owns proof construction and cryptographic checks. +Re-exports the Rust binding entry points used by the consensus layer. +The consensus layer owns the domain-typed wrapper containers. """ from lean_multisig_py import ( @@ -18,330 +14,25 @@ ) from lean_spec.config import LEAN_ENV -from lean_spec.spec.forks.lstar.containers import ( - AggregationBits, - Slot, - ValidatorIndex, - ValidatorIndices, -) -from lean_spec.spec.ssz import ByteList512KiB, Bytes32, Container - -from .containers import PublicKey, Signature LOG_INV_RATE: int = 1 if LEAN_ENV == "test" else 2 """Inverse-rate exponent forwarded to the SNARK backend. -- A smaller rate trades verifier cost for prover speed. -- Test mode favors prover speed. +A smaller rate trades verifier cost for prover speed. +Test mode favors prover speed. """ # The environment is fixed for the lifetime of the process. -# -# One setup call covers every aggregation, verification, split, and merge below. -# +# One setup call covers every aggregation, verification, split, and merge. # Per-call invocations then default to the mode established here. setup_prover(mode=LEAN_ENV) -class AggregationError(Exception): - """Raised when aggregation, merging, splitting, or verification fails.""" - - -class TypeOneMultiSignature(Container): - """Single-message proof aggregating signatures from many validators. - - Every validator signs the same message for the same slot. - - The message and slot stay outside the proof. - The verifier rederives them from the block body it already trusts. - """ - - model_config = Container.model_config | {"frozen": True} - - participants: AggregationBits - """Bitfield indicating which validators contributed signatures.""" - - proof: ByteList512KiB - """Aggregated proof bytes in compact no-pubkeys representation.""" - - @classmethod - def aggregate( - cls, - children: list[tuple["TypeOneMultiSignature", list[PublicKey]]], - raw_xmss: list[tuple[ValidatorIndex, PublicKey, Signature]], - message: Bytes32, - slot: Slot, - ) -> "TypeOneMultiSignature": - """Fold fresh signatures and child proofs into one single-message proof. - - # Overview - - Two kinds of contribution merge into one proof. - - - A fresh signer contributes a single raw signature. - - A child proof contributes an already-aggregated bundle of signers. - - The result names the union of every contributing validator. - The prover compresses all contributions into one proof over the shared message. - - # Why the index travels with each fresh signer - - A public key carries no validator index on its own. - Pairing the index with each fresh entry lets the bitfield be derived, not passed in. - An empty list of fresh signers simply contributes no indices. - - Args: - children: Child proofs, each paired with the public keys it names. - raw_xmss: Fresh entries, each carrying its validator index, public key, and signature. - message: The 32-byte message every signer signed. - slot: The slot every signer signed for. - - Returns: - A single-message proof covering the union of all participants. - - Raises: - AggregationError: When the prover rejects the inputs. - """ - # Phase 1: union every contributing validator index. - # - # Fresh signers bring their own index. - # Child proofs expose theirs through the participant bitfield. - all_indices = {vid for vid, _, _ in raw_xmss}.union( - *(child.participants.to_validator_indices() for child, _ in children) - ) - participants = ValidatorIndices(data=sorted(all_indices)).to_aggregation_bits() - - # Phase 2: serialize inputs to the prover's wire format. - raw_pubkeys_ssz = [pk.encode_bytes() for _, pk, _ in raw_xmss] - raw_signatures_ssz = [sig.encode_bytes() for _, _, sig in raw_xmss] - children_bytes = [ - ([pk.encode_bytes() for pk in pubkeys], bytes(child.proof.data)) - for child, pubkeys in children - ] - - # Phase 3: hand off to the Rust prover. - # The mode argument routes the call to the matching backend bytecode. - try: - _, type1_wire = aggregate_type_1( - raw_pubkeys_ssz, - raw_signatures_ssz, - bytes(message), - int(slot), - LOG_INV_RATE, - children_bytes or None, - mode=LEAN_ENV, - ) - except Exception as exc: - raise AggregationError(str(exc)) from exc - - return cls(participants=participants, proof=ByteList512KiB(data=type1_wire)) - - def verify( - self, - public_keys: list[PublicKey], - message: Bytes32, - slot: Slot, - ) -> None: - """Verify this single-message Type-1 proof against a pubkey set. - - Args: - public_keys: Pubkeys for the validators named by participants. - message: Message bound by the proof. - slot: Slot bound by the proof. - - Raises: - AggregationError: When the pubkey count does not match the bitfield - or the Rust verifier rejects the proof. - """ - # The bitfield names one validator per set bit. - # The caller must supply exactly that many keys, in the same order. - # A miscount would otherwise fail deep in the verifier with an opaque error. - expected = len(self.participants.to_validator_indices()) - if len(public_keys) != expected: - raise AggregationError( - f"Type-1 verify expected {expected} pubkeys for participants, " - f"got {len(public_keys)}" - ) - - # Hand the resolved keys, message, and slot to the Rust verifier. - # The mode argument selects the matching backend bytecode. - try: - verify_type_1( - [pk.encode_bytes() for pk in public_keys], - bytes(message), - int(slot), - bytes(self.proof.data), - mode=LEAN_ENV, - ) - except Exception as exc: - raise AggregationError(f"Type-1 verification failed: {exc}") from exc - - def __hash__(self) -> int: - """Content-deterministic hash via SSZ encoding.""" - return hash(self.encode_bytes()) - - -class TypeTwoMultiSignature(Container): - """Merged proof covering many distinct messages. - - Each component is a single-message proof over its own message. - Merging binds the components into one proof the block can carry whole. - - A signed block stores this proof as a single serialized blob. - """ - - model_config = Container.model_config | {"frozen": True} - - proof: ByteList512KiB - """Compact no-pubkeys serialized Type-2 proof bytes.""" - - @classmethod - def aggregate( - cls, - parts: list[TypeOneMultiSignature], - public_keys_per_part: list[list[PublicKey]], - ) -> "TypeTwoMultiSignature": - """Merge several single-message proofs over distinct messages into one. - - # Why the public keys are passed in - - - A merged proof stores no public keys. - - The prover needs them as external context to fold the components together. - - They cannot be recovered from the proofs, so the caller supplies them. - - Args: - parts: The single-message proofs to merge, one per distinct message. - public_keys_per_part: Public keys for each component, in the same order as the proofs. - - Returns: - A merged proof binding every component to its own message. - - Raises: - AggregationError: When no proofs are given, a pubkey list disagrees - with its participant count, or the prover rejects the inputs. - """ - if not parts: - raise AggregationError("Type-2 aggregate requires at least one Type-1 input") - - # Each component carries the public keys named by its bitfield, in the same order. - # - # A miscount would otherwise fail deep in the prover with an opaque error. - type1_entries: list[tuple[list[bytes], bytes]] = [] - for idx, (part, pubkeys) in enumerate(zip(parts, public_keys_per_part, strict=True)): - expected = len(part.participants.to_validator_indices()) - if len(pubkeys) != expected: - raise AggregationError( - f"Type-2 aggregate entry {idx} expected {expected} pubkeys, got {len(pubkeys)}" - ) - type1_entries.append(([pk.encode_bytes() for pk in pubkeys], bytes(part.proof.data))) - - # Hand the per-component keys and proof bytes to the Rust prover. - # - # The mode argument selects the matching backend bytecode. - try: - _, type2_wire = merge_many_type_1(type1_entries, LOG_INV_RATE, mode=LEAN_ENV) - except Exception as exc: - raise AggregationError(str(exc)) from exc - - return cls(proof=ByteList512KiB(data=type2_wire)) - - def split_by_msg( - self, - message: Bytes32, - public_keys_per_message: list[list[PublicKey]], - participants: AggregationBits, - ) -> TypeOneMultiSignature: - """Recover the Type-1 proof bound to one message from this Type-2 merge. - - # Why the layout and participants are passed in - - - A merged proof stores neither the public keys nor the participant bitfields. - - The prover needs the original key layout to isolate one component. - - The caller supplies both, drawn from the block attestation this component binds. - - Args: - message: Message that selects the Type-1 component. - public_keys_per_message: Pubkey layout this Type-2 was built with. - participants: Bitfield naming the validators of the recovered component. - - Returns: - The Type-1 proof bound to the message. - - Raises: - AggregationError: When the Rust binding rejects the split. - """ - # Each component carries the public keys named by its bitfield, in the same order. - pub_keys_per_component_ssz: list[list[bytes]] = [ - [pk.encode_bytes() for pk in pks] for pks in public_keys_per_message - ] - - # Hand the key layout, merged proof, and selector message to the Rust prover. - # - # The mode argument selects the matching backend bytecode. - try: - _, type1_wire = split_type_2_by_msg( - pub_keys_per_component_ssz, - bytes(self.proof.data), - bytes(message), - LOG_INV_RATE, - mode=LEAN_ENV, - ) - except Exception as exc: - raise AggregationError(f"Type-2 split failed: {exc}") from exc - - return TypeOneMultiSignature( - participants=participants, - proof=ByteList512KiB(data=type1_wire), - ) - - def verify( - self, - public_keys_per_message: list[list[PublicKey]], - messages: list[tuple[Bytes32, Slot]], - ) -> None: - """Verify this multi-message proof against its per-component bindings. - - # The message bindings - - Each component is checked against one message and slot supplied by the caller. - Without that binding the proof would accept attacker-chosen data resolving to the same keys. - The parallel lists pin every component to the message it actually signed. - - Args: - public_keys_per_message: Public keys for each component, in component order. - messages: Message-slot pair each component is bound to, parallel to the keys. - - Raises: - AggregationError: When the two lists disagree in length, or the verifier rejects. - """ - # Each component needs exactly one message-slot binding. - # - # A length mismatch would leave components unbound or misaligned. - if len(messages) != len(public_keys_per_message): - raise AggregationError( - f"Type-2 verify expected {len(public_keys_per_message)} message bindings, " - f"got {len(messages)}" - ) - - # Serialize the key layout and the per-component message bindings. - pub_keys_per_component_ssz: list[list[bytes]] = [ - [pk.encode_bytes() for pk in pks] for pks in public_keys_per_message - ] - expected_messages = [(bytes(msg), int(slot)) for msg, slot in messages] - - # Hand the layout, bindings, and merged proof to the Rust verifier. - # - # The mode argument selects the matching backend bytecode. - try: - verify_type_2_with_messages( - pub_keys_per_component_ssz, - expected_messages, - bytes(self.proof.data), - mode=LEAN_ENV, - ) - except Exception as exc: - raise AggregationError(f"Type-2 verification failed: {exc}") from exc - - def __hash__(self) -> int: - """Content-deterministic hash via SSZ encoding.""" - return hash(self.encode_bytes()) +__all__ = [ + "LOG_INV_RATE", + "aggregate_type_1", + "merge_many_type_1", + "split_type_2_by_msg", + "verify_type_1", + "verify_type_2_with_messages", +] diff --git a/src/lean_spec/spec/crypto/xmss/containers.py b/src/lean_spec/spec/crypto/xmss/containers.py index 610917d4..94f61e9e 100644 --- a/src/lean_spec/spec/crypto/xmss/containers.py +++ b/src/lean_spec/spec/crypto/xmss/containers.py @@ -5,7 +5,7 @@ from pydantic import field_serializer, model_serializer from lean_spec.base import StrictBaseModel -from lean_spec.spec.forks.lstar.containers import Slot +from lean_spec.spec.forks.lstar.slot import Slot from lean_spec.spec.ssz import Uint64 from lean_spec.spec.ssz.container import Container diff --git a/src/lean_spec/spec/crypto/xmss/interface.py b/src/lean_spec/spec/crypto/xmss/interface.py index ecfd3493..b89436e1 100644 --- a/src/lean_spec/spec/crypto/xmss/interface.py +++ b/src/lean_spec/spec/crypto/xmss/interface.py @@ -2,7 +2,7 @@ from lean_spec.base import StrictBaseModel from lean_spec.config import LEAN_ENV -from lean_spec.spec.forks.lstar.containers import Slot +from lean_spec.spec.forks.lstar.slot import Slot from lean_spec.spec.ssz import Bytes32, Uint64 from .constants import PROD_CONFIG, TEST_CONFIG, XmssConfig diff --git a/src/lean_spec/spec/forks/lstar/aggregation_select.py b/src/lean_spec/spec/forks/lstar/aggregation_select.py index 27081e5b..6c96ba63 100644 --- a/src/lean_spec/spec/forks/lstar/aggregation_select.py +++ b/src/lean_spec/spec/forks/lstar/aggregation_select.py @@ -1,7 +1,6 @@ """Greedy proof selection for lstar block production.""" -from lean_spec.spec.crypto.xmss.aggregation import TypeOneMultiSignature -from lean_spec.spec.forks.lstar.containers import ValidatorIndex +from lean_spec.spec.forks.lstar.containers import TypeOneMultiSignature, ValidatorIndex def select_greedily( diff --git a/src/lean_spec/spec/forks/lstar/containers.py b/src/lean_spec/spec/forks/lstar/containers.py index bcadc8e3..cb1d3473 100644 --- a/src/lean_spec/spec/forks/lstar/containers.py +++ b/src/lean_spec/spec/forks/lstar/containers.py @@ -1,81 +1,32 @@ """The container types for the Lean consensus specification.""" -import math from typing import Final, Self +from lean_spec.config import LEAN_ENV +from lean_spec.node.chain.config import HISTORICAL_ROOTS_LIMIT +from lean_spec.spec.crypto.xmss.aggregation import ( + LOG_INV_RATE, + aggregate_type_1, + merge_many_type_1, + split_type_2_by_msg, + verify_type_1, + verify_type_2_with_messages, +) +from lean_spec.spec.crypto.xmss.containers import PublicKey, Signature from lean_spec.spec.ssz import Boolean, ByteList512KiB, Bytes32, Bytes52, Container, SSZList, Uint64 from lean_spec.spec.ssz.bitfields import BaseBitlist -VALIDATOR_REGISTRY_LIMIT: Final = Uint64(2**12) -"""The maximum number of validators that can be in the registry.""" - -IMMEDIATE_JUSTIFICATION_WINDOW: Final = 5 -"""First N slots after finalization are always justifiable.""" - - -class Slot(Uint64): - """Represents a slot number as a 64-bit unsigned integer.""" - - def justified_index_after(self, finalized_slot: "Slot") -> int | None: - """ - Return the relative bitfield index for justification tracking. - - Slots at or before the finalized boundary are treated as justified. - Those slots do not have an index in the tracked bitfield. - """ - if self <= finalized_slot: - return None - - # Slot (finalized_slot + 1) maps to index 0. - return int(self - finalized_slot) - 1 - - def is_justifiable_after(self, finalized_slot: "Slot") -> bool: - """ - Checks if this slot is a valid candidate for justification after a given finalized slot. +from .slot import IMMEDIATE_JUSTIFICATION_WINDOW, Slot - According to the 3SF-mini specification, a slot is justifiable if its - distance (`delta`) from the last finalized slot is: - 1. Less than or equal to 5. - 2. A perfect square (e.g., 9, 16, 25...). - 3. A pronic number (of the form x^2 + x, e.g., 6, 12, 20...). - - Args: - finalized_slot: The last slot that was finalized. - - Returns: - True if the slot is justifiable, False otherwise. +__all__ = [ + # Re-exports from the slot module so downstream callers can keep + # importing these names from .containers. + "IMMEDIATE_JUSTIFICATION_WINDOW", + "Slot", +] - Raises: - AssertionError: If this slot is earlier than the finalized slot. - """ - # Ensure the candidate slot is not before the finalized slot. - assert self >= finalized_slot, "Candidate slot must not be before finalized slot" - - # Calculate the distance in slots from the last finalized slot. - # Convert to int for pure arithmetic operations below. - delta = int(self - finalized_slot) - - return ( - # Rule 1: The first N slots after finalization are always justifiable. - # - # Examples: delta = 0, 1, 2, 3, 4, 5 - delta <= IMMEDIATE_JUSTIFICATION_WINDOW - # Rule 2: Slots at perfect square distances are justifiable. - # - # Examples: delta = 1, 4, 9, 16, 25, 36, 49, 64, ... - # Check: integer square root squared equals delta - or math.isqrt(delta) ** 2 == delta - # Rule 3: Slots at pronic number distances are justifiable. - # - # Pronic numbers have the form n(n+1): 2, 6, 12, 20, 30, 42, 56, ... - # Mathematical insight: For pronic delta = n(n+1), we have: - # 4*delta + 1 = 4n(n+1) + 1 = (2n+1)^2 - # Check: 4*delta+1 is an odd perfect square - or ( - math.isqrt(4 * delta + 1) ** 2 == 4 * delta + 1 - and math.isqrt(4 * delta + 1) % 2 == 1 - ) - ) +VALIDATOR_REGISTRY_LIMIT: Final = Uint64(2**12) +"""The maximum number of validators that can be in the registry.""" class SubnetId(Uint64): @@ -173,11 +124,308 @@ def to_aggregation_bits(self) -> AggregationBits: return AggregationBits(data=[Boolean(i in ids) for i in range(max_id + 1)]) -# Deferred until after Slot, ValidatorIndex(es), and AggregationBits are defined. -# Each downstream module imports those types from this file at module-load time. -from lean_spec.node.chain.config import HISTORICAL_ROOTS_LIMIT # noqa: E402 -from lean_spec.spec.crypto.xmss.aggregation import TypeOneMultiSignature # noqa: E402 -from lean_spec.spec.crypto.xmss.containers import PublicKey, Signature # noqa: E402 +class AggregationError(Exception): + """Raised when aggregation, merging, splitting, or verification fails.""" + + +class TypeOneMultiSignature(Container): + """Single-message proof aggregating signatures from many validators. + + Every validator signs the same message for the same slot. + + The message and slot stay outside the proof. + The verifier rederives them from the block body it already trusts. + """ + + model_config = Container.model_config | {"frozen": True} + + participants: AggregationBits + """Bitfield indicating which validators contributed signatures.""" + + proof: ByteList512KiB + """Aggregated proof bytes in compact no-pubkeys representation.""" + + @classmethod + def aggregate( + cls, + children: list[tuple["TypeOneMultiSignature", list[PublicKey]]], + raw_xmss: list[tuple[ValidatorIndex, PublicKey, Signature]], + message: Bytes32, + slot: Slot, + ) -> "TypeOneMultiSignature": + """Fold fresh signatures and child proofs into one single-message proof. + + # Overview + + Two kinds of contribution merge into one proof. + + - A fresh signer contributes a single raw signature. + - A child proof contributes an already-aggregated bundle of signers. + + The result names the union of every contributing validator. + The prover compresses all contributions into one proof over the shared message. + + # Why the index travels with each fresh signer + + A public key carries no validator index on its own. + Pairing the index with each fresh entry lets the bitfield be derived, not passed in. + An empty list of fresh signers simply contributes no indices. + + Args: + children: Child proofs, each paired with the public keys it names. + raw_xmss: Fresh entries, each carrying its validator index, public key, and signature. + message: The 32-byte message every signer signed. + slot: The slot every signer signed for. + + Returns: + A single-message proof covering the union of all participants. + + Raises: + AggregationError: When the prover rejects the inputs. + """ + # Phase 1: union every contributing validator index. + # + # Fresh signers bring their own index. + # Child proofs expose theirs through the participant bitfield. + all_indices = {vid for vid, _, _ in raw_xmss}.union( + *(child.participants.to_validator_indices() for child, _ in children) + ) + participants = ValidatorIndices(data=sorted(all_indices)).to_aggregation_bits() + + # Phase 2: serialize inputs to the prover's wire format. + raw_pubkeys_ssz = [pk.encode_bytes() for _, pk, _ in raw_xmss] + raw_signatures_ssz = [sig.encode_bytes() for _, _, sig in raw_xmss] + children_bytes = [ + ([pk.encode_bytes() for pk in pubkeys], bytes(child.proof.data)) + for child, pubkeys in children + ] + + # Phase 3: hand off to the Rust prover. + # The mode argument routes the call to the matching backend bytecode. + try: + _, type1_wire = aggregate_type_1( + raw_pubkeys_ssz, + raw_signatures_ssz, + bytes(message), + int(slot), + LOG_INV_RATE, + children_bytes or None, + mode=LEAN_ENV, + ) + except Exception as exc: + raise AggregationError(str(exc)) from exc + + return cls(participants=participants, proof=ByteList512KiB(data=type1_wire)) + + def verify( + self, + public_keys: list[PublicKey], + message: Bytes32, + slot: Slot, + ) -> None: + """Verify this single-message Type-1 proof against a pubkey set. + + Args: + public_keys: Pubkeys for the validators named by participants. + message: Message bound by the proof. + slot: Slot bound by the proof. + + Raises: + AggregationError: When the pubkey count does not match the bitfield + or the Rust verifier rejects the proof. + """ + # The bitfield names one validator per set bit. + # The caller must supply exactly that many keys, in the same order. + # A miscount would otherwise fail deep in the verifier with an opaque error. + expected = len(self.participants.to_validator_indices()) + if len(public_keys) != expected: + raise AggregationError( + f"Type-1 verify expected {expected} pubkeys for participants, " + f"got {len(public_keys)}" + ) + + # Hand the resolved keys, message, and slot to the Rust verifier. + # The mode argument selects the matching backend bytecode. + try: + verify_type_1( + [pk.encode_bytes() for pk in public_keys], + bytes(message), + int(slot), + bytes(self.proof.data), + mode=LEAN_ENV, + ) + except Exception as exc: + raise AggregationError(f"Type-1 verification failed: {exc}") from exc + + def __hash__(self) -> int: + """Content-deterministic hash via SSZ encoding.""" + return hash(self.encode_bytes()) + + +class TypeTwoMultiSignature(Container): + """Merged proof covering many distinct messages. + + Each component is a single-message proof over its own message. + Merging binds the components into one proof the block can carry whole. + + A signed block stores this proof as a single serialized blob. + """ + + model_config = Container.model_config | {"frozen": True} + + proof: ByteList512KiB + """Compact no-pubkeys serialized Type-2 proof bytes.""" + + @classmethod + def aggregate( + cls, + parts: list[TypeOneMultiSignature], + public_keys_per_part: list[list[PublicKey]], + ) -> "TypeTwoMultiSignature": + """Merge several single-message proofs over distinct messages into one. + + # Why the public keys are passed in + + - A merged proof stores no public keys. + - The prover needs them as external context to fold the components together. + - They cannot be recovered from the proofs, so the caller supplies them. + + Args: + parts: The single-message proofs to merge, one per distinct message. + public_keys_per_part: Public keys for each component, in the same order as the proofs. + + Returns: + A merged proof binding every component to its own message. + + Raises: + AggregationError: When no proofs are given, a pubkey list disagrees + with its participant count, or the prover rejects the inputs. + """ + if not parts: + raise AggregationError("Type-2 aggregate requires at least one Type-1 input") + + # Each component carries the public keys named by its bitfield, in the same order. + # + # A miscount would otherwise fail deep in the prover with an opaque error. + type1_entries: list[tuple[list[bytes], bytes]] = [] + for idx, (part, pubkeys) in enumerate(zip(parts, public_keys_per_part, strict=True)): + expected = len(part.participants.to_validator_indices()) + if len(pubkeys) != expected: + raise AggregationError( + f"Type-2 aggregate entry {idx} expected {expected} pubkeys, got {len(pubkeys)}" + ) + type1_entries.append(([pk.encode_bytes() for pk in pubkeys], bytes(part.proof.data))) + + # Hand the per-component keys and proof bytes to the Rust prover. + # + # The mode argument selects the matching backend bytecode. + try: + _, type2_wire = merge_many_type_1(type1_entries, LOG_INV_RATE, mode=LEAN_ENV) + except Exception as exc: + raise AggregationError(str(exc)) from exc + + return cls(proof=ByteList512KiB(data=type2_wire)) + + def split_by_msg( + self, + message: Bytes32, + public_keys_per_message: list[list[PublicKey]], + participants: AggregationBits, + ) -> TypeOneMultiSignature: + """Recover the Type-1 proof bound to one message from this Type-2 merge. + + # Why the layout and participants are passed in + + - A merged proof stores neither the public keys nor the participant bitfields. + - The prover needs the original key layout to isolate one component. + - The caller supplies both, drawn from the block attestation this component binds. + + Args: + message: Message that selects the Type-1 component. + public_keys_per_message: Pubkey layout this Type-2 was built with. + participants: Bitfield naming the validators of the recovered component. + + Returns: + The Type-1 proof bound to the message. + + Raises: + AggregationError: When the Rust binding rejects the split. + """ + # Each component carries the public keys named by its bitfield, in the same order. + pub_keys_per_component_ssz: list[list[bytes]] = [ + [pk.encode_bytes() for pk in pks] for pks in public_keys_per_message + ] + + # Hand the key layout, merged proof, and selector message to the Rust prover. + # + # The mode argument selects the matching backend bytecode. + try: + _, type1_wire = split_type_2_by_msg( + pub_keys_per_component_ssz, + bytes(self.proof.data), + bytes(message), + LOG_INV_RATE, + mode=LEAN_ENV, + ) + except Exception as exc: + raise AggregationError(f"Type-2 split failed: {exc}") from exc + + return TypeOneMultiSignature( + participants=participants, + proof=ByteList512KiB(data=type1_wire), + ) + + def verify( + self, + public_keys_per_message: list[list[PublicKey]], + messages: list[tuple[Bytes32, Slot]], + ) -> None: + """Verify this multi-message proof against its per-component bindings. + + # The message bindings + + Each component is checked against one message and slot supplied by the caller. + Without that binding the proof would accept attacker-chosen data resolving to the same keys. + The parallel lists pin every component to the message it actually signed. + + Args: + public_keys_per_message: Public keys for each component, in component order. + messages: Message-slot pair each component is bound to, parallel to the keys. + + Raises: + AggregationError: When the two lists disagree in length, or the verifier rejects. + """ + # Each component needs exactly one message-slot binding. + # + # A length mismatch would leave components unbound or misaligned. + if len(messages) != len(public_keys_per_message): + raise AggregationError( + f"Type-2 verify expected {len(public_keys_per_message)} message bindings, " + f"got {len(messages)}" + ) + + # Serialize the key layout and the per-component message bindings. + pub_keys_per_component_ssz: list[list[bytes]] = [ + [pk.encode_bytes() for pk in pks] for pks in public_keys_per_message + ] + expected_messages = [(bytes(msg), int(slot)) for msg, slot in messages] + + # Hand the layout, bindings, and merged proof to the Rust verifier. + # + # The mode argument selects the matching backend bytecode. + try: + verify_type_2_with_messages( + pub_keys_per_component_ssz, + expected_messages, + bytes(self.proof.data), + mode=LEAN_ENV, + ) + except Exception as exc: + raise AggregationError(f"Type-2 verification failed: {exc}") from exc + + def __hash__(self) -> int: + """Content-deterministic hash via SSZ encoding.""" + return hash(self.encode_bytes()) class Config(Container): diff --git a/src/lean_spec/spec/forks/lstar/slot.py b/src/lean_spec/spec/forks/lstar/slot.py new file mode 100644 index 00000000..e0d20e00 --- /dev/null +++ b/src/lean_spec/spec/forks/lstar/slot.py @@ -0,0 +1,79 @@ +"""Slot primitive shared by the consensus and crypto layers. + +The crypto signing scheme binds each signature to a specific slot. +Hosting the type here lets the crypto layer name the slot without +importing the rest of the consensus container module. +""" + +import math +from typing import Final + +from lean_spec.spec.ssz import Uint64 + +IMMEDIATE_JUSTIFICATION_WINDOW: Final = 5 +"""First N slots after finalization are always justifiable.""" + + +class Slot(Uint64): + """Represents a slot number as a 64-bit unsigned integer.""" + + def justified_index_after(self, finalized_slot: "Slot") -> int | None: + """ + Return the relative bitfield index for justification tracking. + + Slots at or before the finalized boundary are treated as justified. + Those slots do not have an index in the tracked bitfield. + """ + if self <= finalized_slot: + return None + + # Slot (finalized_slot + 1) maps to index 0. + return int(self - finalized_slot) - 1 + + def is_justifiable_after(self, finalized_slot: "Slot") -> bool: + """ + Checks if this slot is a valid candidate for justification after a given finalized slot. + + According to the 3SF-mini specification, a slot is justifiable if its + distance (`delta`) from the last finalized slot is: + 1. Less than or equal to 5. + 2. A perfect square (e.g., 9, 16, 25...). + 3. A pronic number (of the form x^2 + x, e.g., 6, 12, 20...). + + Args: + finalized_slot: The last slot that was finalized. + + Returns: + True if the slot is justifiable, False otherwise. + + Raises: + AssertionError: If this slot is earlier than the finalized slot. + """ + # Ensure the candidate slot is not before the finalized slot. + assert self >= finalized_slot, "Candidate slot must not be before finalized slot" + + # Calculate the distance in slots from the last finalized slot. + # Convert to int for pure arithmetic operations below. + delta = int(self - finalized_slot) + + return ( + # Rule 1: The first N slots after finalization are always justifiable. + # + # Examples: delta = 0, 1, 2, 3, 4, 5 + delta <= IMMEDIATE_JUSTIFICATION_WINDOW + # Rule 2: Slots at perfect square distances are justifiable. + # + # Examples: delta = 1, 4, 9, 16, 25, 36, 49, 64, ... + # Check: integer square root squared equals delta + or math.isqrt(delta) ** 2 == delta + # Rule 3: Slots at pronic number distances are justifiable. + # + # Pronic numbers have the form n(n+1): 2, 6, 12, 20, 30, 42, 56, ... + # Mathematical insight: For pronic delta = n(n+1), we have: + # 4*delta + 1 = 4n(n+1) + 1 = (2n+1)^2 + # Check: 4*delta+1 is an odd perfect square + or ( + math.isqrt(4 * delta + 1) ** 2 == 4 * delta + 1 + and math.isqrt(4 * delta + 1) % 2 == 1 + ) + ) diff --git a/src/lean_spec/spec/forks/lstar/spec.py b/src/lean_spec/spec/forks/lstar/spec.py index 765d68f4..b6b84f12 100644 --- a/src/lean_spec/spec/forks/lstar/spec.py +++ b/src/lean_spec/spec/forks/lstar/spec.py @@ -19,17 +19,13 @@ observe_state_transition, ) from lean_spec.spec.crypto.merkleization import hash_tree_root -from lean_spec.spec.crypto.xmss.aggregation import ( - AggregationError, - TypeOneMultiSignature, - TypeTwoMultiSignature, -) from lean_spec.spec.crypto.xmss.containers import PublicKey from lean_spec.spec.crypto.xmss.interface import TARGET_SIGNATURE_SCHEME from lean_spec.spec.forks.lstar.aggregation_select import select_greedily from lean_spec.spec.forks.lstar.containers import ( AggregatedAttestation, AggregatedAttestations, + AggregationError, AttestationData, Block, BlockBody, @@ -45,6 +41,8 @@ SignedBlock, Slot, State, + TypeOneMultiSignature, + TypeTwoMultiSignature, ValidatorIndex, Validators, ) diff --git a/src/lean_spec/spec/forks/lstar/store.py b/src/lean_spec/spec/forks/lstar/store.py index 501e712d..e244a54e 100644 --- a/src/lean_spec/spec/forks/lstar/store.py +++ b/src/lean_spec/spec/forks/lstar/store.py @@ -12,12 +12,12 @@ from lean_spec.base import StrictBaseModel from lean_spec.node.chain.clock import Interval -from lean_spec.spec.crypto.xmss.aggregation import TypeOneMultiSignature from lean_spec.spec.crypto.xmss.containers import Signature from lean_spec.spec.forks.lstar.containers import ( AttestationData, Checkpoint, Config, + TypeOneMultiSignature, ValidatorIndex, ) from lean_spec.spec.ssz import Bytes32 diff --git a/tests/consensus/lstar/ssz/test_consensus_containers.py b/tests/consensus/lstar/ssz/test_consensus_containers.py index 9a6be63e..0c8d22aa 100644 --- a/tests/consensus/lstar/ssz/test_consensus_containers.py +++ b/tests/consensus/lstar/ssz/test_consensus_containers.py @@ -4,7 +4,6 @@ from consensus_testing import SSZTestFiller from consensus_testing.keys import create_dummy_signature -from lean_spec.spec.crypto.xmss.aggregation import TypeOneMultiSignature from lean_spec.spec.forks import AggregationBits, Checkpoint, Slot, ValidatorIndex from lean_spec.spec.forks.lstar import State from lean_spec.spec.forks.lstar.containers import ( @@ -23,6 +22,7 @@ SignedAggregatedAttestation, SignedAttestation, SignedBlock, + TypeOneMultiSignature, Validator, Validators, ) diff --git a/tests/consensus/lstar/ssz/test_xmss_containers.py b/tests/consensus/lstar/ssz/test_xmss_containers.py index 311e6ea3..5e9427e4 100644 --- a/tests/consensus/lstar/ssz/test_xmss_containers.py +++ b/tests/consensus/lstar/ssz/test_xmss_containers.py @@ -6,10 +6,6 @@ from lean_spec.spec.crypto.koalabear import Fp from lean_spec.spec.crypto.xmss import PublicKey -from lean_spec.spec.crypto.xmss.aggregation import ( - TypeOneMultiSignature, - TypeTwoMultiSignature, -) from lean_spec.spec.crypto.xmss.constants import TARGET_CONFIG from lean_spec.spec.crypto.xmss.merkle import HashTreeLayer from lean_spec.spec.crypto.xmss.types import ( @@ -19,6 +15,10 @@ Parameter, ) from lean_spec.spec.forks import AggregationBits, Slot, ValidatorIndex +from lean_spec.spec.forks.lstar.containers import ( + TypeOneMultiSignature, + TypeTwoMultiSignature, +) from lean_spec.spec.ssz import Boolean, ByteList512KiB, Bytes32, Uint64 pytestmark = pytest.mark.valid_until("Lstar") diff --git a/tests/lean_spec/helpers/builders.py b/tests/lean_spec/helpers/builders.py index db23572d..00e9af0d 100644 --- a/tests/lean_spec/helpers/builders.py +++ b/tests/lean_spec/helpers/builders.py @@ -22,7 +22,6 @@ from lean_spec.node.sync.service import SyncService from lean_spec.spec.crypto.koalabear import Fp from lean_spec.spec.crypto.merkleization import hash_tree_root -from lean_spec.spec.crypto.xmss.aggregation import TypeOneMultiSignature, TypeTwoMultiSignature from lean_spec.spec.crypto.xmss.constants import TARGET_CONFIG from lean_spec.spec.crypto.xmss.containers import Signature from lean_spec.spec.crypto.xmss.types import ( @@ -42,6 +41,8 @@ SignedAggregatedAttestation, SignedAttestation, SignedBlock, + TypeOneMultiSignature, + TypeTwoMultiSignature, Validator, Validators, ) diff --git a/tests/lean_spec/node/validator/test_service.py b/tests/lean_spec/node/validator/test_service.py index a6d4bcd5..b5090e2f 100644 --- a/tests/lean_spec/node/validator/test_service.py +++ b/tests/lean_spec/node/validator/test_service.py @@ -17,7 +17,6 @@ from lean_spec.node.validator.registry import ValidatorEntry from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.crypto.xmss import TARGET_SIGNATURE_SCHEME -from lean_spec.spec.crypto.xmss.aggregation import TypeOneMultiSignature, TypeTwoMultiSignature from lean_spec.spec.forks import Slot, ValidatorIndex from lean_spec.spec.forks.lstar import Store from lean_spec.spec.forks.lstar.containers import ( @@ -25,6 +24,8 @@ Block, SignedAttestation, SignedBlock, + TypeOneMultiSignature, + TypeTwoMultiSignature, ) from lean_spec.spec.forks.lstar.spec import LstarSpec from lean_spec.spec.ssz import Bytes32, Uint64 diff --git a/tests/lean_spec/spec/crypto/xmss/test_aggregation.py b/tests/lean_spec/spec/crypto/xmss/test_aggregation.py index 1e67f586..e86f65ac 100644 --- a/tests/lean_spec/spec/crypto/xmss/test_aggregation.py +++ b/tests/lean_spec/spec/crypto/xmss/test_aggregation.py @@ -6,12 +6,12 @@ from consensus_testing.keys import XmssKeyManager from lean_spec.spec.crypto.merkleization import hash_tree_root -from lean_spec.spec.crypto.xmss.aggregation import ( +from lean_spec.spec.forks import Checkpoint, Slot, ValidatorIndex +from lean_spec.spec.forks.lstar.containers import ( AggregationError, TypeOneMultiSignature, TypeTwoMultiSignature, ) -from lean_spec.spec.forks import Checkpoint, Slot, ValidatorIndex from lean_spec.spec.ssz import ByteList512KiB from tests.lean_spec.helpers import make_attestation_data_simple, make_bytes32 diff --git a/tests/lean_spec/spec/forks/lstar/forkchoice/test_attestation_target.py b/tests/lean_spec/spec/forks/lstar/forkchoice/test_attestation_target.py index 6a42f596..68b9cd02 100644 --- a/tests/lean_spec/spec/forks/lstar/forkchoice/test_attestation_target.py +++ b/tests/lean_spec/spec/forks/lstar/forkchoice/test_attestation_target.py @@ -8,7 +8,6 @@ from lean_spec.node.chain.clock import Interval from lean_spec.node.chain.config import JUSTIFICATION_LOOKBACK_SLOTS from lean_spec.spec.crypto.merkleization import hash_tree_root -from lean_spec.spec.crypto.xmss.aggregation import TypeOneMultiSignature, TypeTwoMultiSignature from lean_spec.spec.forks import Checkpoint, Slot, ValidatorIndex from lean_spec.spec.forks.lstar import Store from lean_spec.spec.forks.lstar.containers import ( @@ -16,6 +15,8 @@ AttestationData, SignedAttestation, SignedBlock, + TypeOneMultiSignature, + TypeTwoMultiSignature, ) from lean_spec.spec.forks.lstar.spec import LstarSpec from lean_spec.spec.ssz import ByteList512KiB, Bytes32 diff --git a/tests/lean_spec/spec/forks/lstar/forkchoice/test_compute_block_weights.py b/tests/lean_spec/spec/forks/lstar/forkchoice/test_compute_block_weights.py index 08033814..2d0b6415 100644 --- a/tests/lean_spec/spec/forks/lstar/forkchoice/test_compute_block_weights.py +++ b/tests/lean_spec/spec/forks/lstar/forkchoice/test_compute_block_weights.py @@ -5,10 +5,9 @@ import pytest from lean_spec.spec.crypto.merkleization import hash_tree_root -from lean_spec.spec.crypto.xmss.aggregation import TypeOneMultiSignature from lean_spec.spec.forks import Checkpoint, Slot, ValidatorIndex, ValidatorIndices from lean_spec.spec.forks.lstar import Store -from lean_spec.spec.forks.lstar.containers import AttestationData +from lean_spec.spec.forks.lstar.containers import AttestationData, TypeOneMultiSignature from lean_spec.spec.forks.lstar.spec import LstarSpec from lean_spec.spec.ssz.byte_arrays import ByteList512KiB, Bytes32 from tests.lean_spec.helpers import make_bytes32, make_signed_block diff --git a/tests/lean_spec/spec/forks/lstar/forkchoice/test_store_attestations.py b/tests/lean_spec/spec/forks/lstar/forkchoice/test_store_attestations.py index a42ea06d..79479df2 100644 --- a/tests/lean_spec/spec/forks/lstar/forkchoice/test_store_attestations.py +++ b/tests/lean_spec/spec/forks/lstar/forkchoice/test_store_attestations.py @@ -8,13 +8,13 @@ from lean_spec.node.chain.clock import Interval from lean_spec.node.chain.config import INTERVALS_PER_SLOT from lean_spec.spec.crypto.merkleization import hash_tree_root -from lean_spec.spec.crypto.xmss.aggregation import TypeOneMultiSignature from lean_spec.spec.forks import Checkpoint, Slot, ValidatorIndex from lean_spec.spec.forks.lstar import AttestationSignatureEntry from lean_spec.spec.forks.lstar.containers import ( AttestationData, SignedAggregatedAttestation, SignedAttestation, + TypeOneMultiSignature, ) from lean_spec.spec.forks.lstar.spec import LstarSpec from lean_spec.spec.ssz import ByteList512KiB, Bytes32 diff --git a/tests/lean_spec/spec/forks/lstar/forkchoice/test_store_pruning.py b/tests/lean_spec/spec/forks/lstar/forkchoice/test_store_pruning.py index f76d8f48..652e7422 100644 --- a/tests/lean_spec/spec/forks/lstar/forkchoice/test_store_pruning.py +++ b/tests/lean_spec/spec/forks/lstar/forkchoice/test_store_pruning.py @@ -1,8 +1,8 @@ """Tests for Store attestation data pruning.""" -from lean_spec.spec.crypto.xmss.aggregation import TypeOneMultiSignature from lean_spec.spec.forks import Slot, ValidatorIndex, ValidatorIndices from lean_spec.spec.forks.lstar import AttestationSignatureEntry, Store +from lean_spec.spec.forks.lstar.containers import TypeOneMultiSignature from lean_spec.spec.forks.lstar.spec import LstarSpec from lean_spec.spec.ssz import ByteList512KiB, Bytes32 from tests.lean_spec.helpers import ( diff --git a/tests/lean_spec/spec/forks/lstar/forkchoice/test_validator.py b/tests/lean_spec/spec/forks/lstar/forkchoice/test_validator.py index 94e9aefc..4072dda0 100644 --- a/tests/lean_spec/spec/forks/lstar/forkchoice/test_validator.py +++ b/tests/lean_spec/spec/forks/lstar/forkchoice/test_validator.py @@ -5,7 +5,6 @@ from lean_spec.node.chain.clock import Interval from lean_spec.spec.crypto.merkleization import hash_tree_root -from lean_spec.spec.crypto.xmss.aggregation import TypeOneMultiSignature from lean_spec.spec.forks import Checkpoint, Slot, ValidatorIndex from lean_spec.spec.forks.lstar import AttestationSignatureEntry, Store from lean_spec.spec.forks.lstar.containers import ( @@ -15,6 +14,7 @@ BlockBody, Config, SignedAttestation, + TypeOneMultiSignature, ) from lean_spec.spec.forks.lstar.spec import LstarSpec from lean_spec.spec.ssz import Bytes32, Uint64 From 809a26f339cf5f0ae626fca512dc76b0c0bd7f4e Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Fri, 29 May 2026 14:54:26 +0200 Subject: [PATCH 2/2] refactor(crypto/xmss): delete the aggregation shim After the multi-signature containers moved to the consensus layer, the aggregation module shrank to a re-export shell with one import-time side effect. With exactly one consumer (lstar/containers.py), the indirection added cost without abstraction. Distribute the three concerns: - Rust binding imports go directly into lstar/containers.py from lean_multisig_py. - LOG_INV_RATE moves next to its sole caller in lstar/containers.py. - setup_prover(mode=LEAN_ENV) moves into crypto/xmss/__init__.py, alongside the other LEAN_ENV-driven xmss bootstrap. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lean_spec/spec/crypto/xmss/__init__.py | 10 +++++ src/lean_spec/spec/crypto/xmss/aggregation.py | 38 ------------------- src/lean_spec/spec/forks/lstar/containers.py | 15 ++++++-- 3 files changed, 21 insertions(+), 42 deletions(-) delete mode 100644 src/lean_spec/spec/crypto/xmss/aggregation.py diff --git a/src/lean_spec/spec/crypto/xmss/__init__.py b/src/lean_spec/spec/crypto/xmss/__init__.py index 3fd1f7ed..f1a027f7 100644 --- a/src/lean_spec/spec/crypto/xmss/__init__.py +++ b/src/lean_spec/spec/crypto/xmss/__init__.py @@ -7,9 +7,19 @@ https://eprint.iacr.org/2026/016.pdf """ +from lean_multisig_py import setup_prover + +from lean_spec.config import LEAN_ENV + from .containers import PublicKey, SecretKey from .interface import TARGET_SIGNATURE_SCHEME, GeneralizedXmssScheme +# Side effect: configures the Rust prover for the lifetime of the process. +# One call covers every aggregation, verification, split, and merge. +# Per-call invocations then default to the mode established here. +setup_prover(mode=LEAN_ENV) + + __all__ = [ "GeneralizedXmssScheme", "PublicKey", diff --git a/src/lean_spec/spec/crypto/xmss/aggregation.py b/src/lean_spec/spec/crypto/xmss/aggregation.py deleted file mode 100644 index 69f93ee7..00000000 --- a/src/lean_spec/spec/crypto/xmss/aggregation.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Byte-level prover binding for multi-signature aggregation. - -Re-exports the Rust binding entry points used by the consensus layer. -The consensus layer owns the domain-typed wrapper containers. -""" - -from lean_multisig_py import ( - aggregate_type_1, - merge_many_type_1, - setup_prover, - split_type_2_by_msg, - verify_type_1, - verify_type_2_with_messages, -) - -from lean_spec.config import LEAN_ENV - -LOG_INV_RATE: int = 1 if LEAN_ENV == "test" else 2 -"""Inverse-rate exponent forwarded to the SNARK backend. - -A smaller rate trades verifier cost for prover speed. -Test mode favors prover speed. -""" - -# The environment is fixed for the lifetime of the process. -# One setup call covers every aggregation, verification, split, and merge. -# Per-call invocations then default to the mode established here. -setup_prover(mode=LEAN_ENV) - - -__all__ = [ - "LOG_INV_RATE", - "aggregate_type_1", - "merge_many_type_1", - "split_type_2_by_msg", - "verify_type_1", - "verify_type_2_with_messages", -] diff --git a/src/lean_spec/spec/forks/lstar/containers.py b/src/lean_spec/spec/forks/lstar/containers.py index cb1d3473..6416f8d8 100644 --- a/src/lean_spec/spec/forks/lstar/containers.py +++ b/src/lean_spec/spec/forks/lstar/containers.py @@ -2,16 +2,16 @@ from typing import Final, Self -from lean_spec.config import LEAN_ENV -from lean_spec.node.chain.config import HISTORICAL_ROOTS_LIMIT -from lean_spec.spec.crypto.xmss.aggregation import ( - LOG_INV_RATE, +from lean_multisig_py import ( aggregate_type_1, merge_many_type_1, split_type_2_by_msg, verify_type_1, verify_type_2_with_messages, ) + +from lean_spec.config import LEAN_ENV +from lean_spec.node.chain.config import HISTORICAL_ROOTS_LIMIT from lean_spec.spec.crypto.xmss.containers import PublicKey, Signature from lean_spec.spec.ssz import Boolean, ByteList512KiB, Bytes32, Bytes52, Container, SSZList, Uint64 from lean_spec.spec.ssz.bitfields import BaseBitlist @@ -28,6 +28,13 @@ VALIDATOR_REGISTRY_LIMIT: Final = Uint64(2**12) """The maximum number of validators that can be in the registry.""" +LOG_INV_RATE: int = 1 if LEAN_ENV == "test" else 2 +"""Inverse-rate exponent forwarded to the SNARK backend. + +A smaller rate trades verifier cost for prover speed. +Test mode favors prover speed. +""" + class SubnetId(Uint64): """Subnet identifier (0-63) for attestation subnet partitioning."""