diff --git a/packages/testing/src/consensus_testing/keys.py b/packages/testing/src/consensus_testing/keys.py index ad25affe3..a5b38b545 100755 --- a/packages/testing/src/consensus_testing/keys.py +++ b/packages/testing/src/consensus_testing/keys.py @@ -41,13 +41,10 @@ from lean_spec.config import LEAN_ENV from lean_spec.forks.lstar.containers import AttestationData -from lean_spec.forks.lstar.containers.block.types import ( - AggregatedAttestations, - AttestationSignatures, -) +from lean_spec.forks.lstar.containers.block.types import AggregatedAttestations from lean_spec.subspecs.koalabear import Fp from lean_spec.subspecs.ssz.hash import hash_tree_root -from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof +from lean_spec.subspecs.xmss.aggregation import TypeOneInfo, TypeOneMultiSignature from lean_spec.subspecs.xmss.constants import TARGET_CONFIG from lean_spec.subspecs.xmss.containers import PublicKey, SecretKey, Signature, ValidatorKeyPair from lean_spec.subspecs.xmss.interface import ( @@ -61,7 +58,15 @@ HashTreeOpening, Randomness, ) -from lean_spec.types import Bytes32, Slot, Uint64, ValidatorIndex, ValidatorIndices +from lean_spec.types import ( + AggregationBits, + ByteListMiB, + Bytes32, + Slot, + Uint64, + ValidatorIndex, + ValidatorIndices, +) SecretField = Literal["attestation_secret", "proposal_secret"] """Discriminator for which secret key to load from a validator key pair.""" @@ -122,6 +127,21 @@ def create_dummy_signature() -> Signature: ) +def create_dummy_type_1(participants: AggregationBits) -> TypeOneMultiSignature: + """Build a structurally valid Type-1 proof with empty proof bytes. + + Skips the lean_multisig_py binding entirely so tests that only check + the proof's shape (participants) stay fast. Verifiers will reject the + empty proof bytes, so this must only be used in tests that do not + exercise cryptographic verification. + """ + placeholder = ByteListMiB(data=b"") + return TypeOneMultiSignature( + info=TypeOneInfo(participants=participants, proof=placeholder), + proof=placeholder, + ) + + DEFAULT_MAX_SLOT = Slot(10) """ Default max slot for the shared key manager. @@ -508,18 +528,22 @@ def sign_and_aggregate( self, validator_ids: list[ValidatorIndex], attestation_data: AttestationData, - ) -> AggregatedSignatureProof: + ) -> TypeOneMultiSignature: """ - Sign attestation data with each validator and aggregate into a single proof. + Sign attestation_data with each validator and aggregate into a Type-1 proof. - Convenience method for the common sign-each-validator-then-aggregate pattern. + Each validator's XMSS attestation key signs the attestation data + root. The signatures are then handed to the multi-signature + binding to produce a single cryptographically valid Type-1 proof + binding all participants to (data, slot). Args: - validator_ids: Validators to sign with. - attestation_data: The attestation data to sign. + validator_ids: Validators that contribute signatures, in the + order they appear in the participant bitfield. + attestation_data: The attestation data the proof binds to. Returns: - Aggregated signature proof combining all validators' signatures. + Cryptographically valid Type-1 proof covering validator_ids. """ raw_xmss = [ ( @@ -528,34 +552,34 @@ def sign_and_aggregate( ) for vid in validator_ids ] - - xmss_participants = ValidatorIndices(data=validator_ids).to_aggregation_bits() - - return AggregatedSignatureProof.aggregate( - xmss_participants=xmss_participants, + return TypeOneMultiSignature.aggregate( children=[], raw_xmss=raw_xmss, + xmss_participants=ValidatorIndices(data=validator_ids).to_aggregation_bits(), message=hash_tree_root(attestation_data), slot=attestation_data.slot, ) - def build_attestation_signatures( + def build_attestation_proofs( self, aggregated_attestations: AggregatedAttestations, signature_lookup: Mapping[AttestationData, Mapping[ValidatorIndex, Signature]] | None = None, - ) -> AttestationSignatures: + ) -> list[TypeOneMultiSignature]: """ - Produce aggregated signature proofs for a list of attestations. + Produce per-attestation Type-1 proofs aligned with the given attestations. For each aggregated attestation: - 1. Identify participating validators from the aggregation bitfield - 2. Collect each participant's public key and individual signature - 3. Combine them into a single aggregated proof for the leanVM verifier + 1. Identify participating validators from the aggregation bitfield. + 2. Collect each participant's attestation public key and signature. + 3. Combine them into a single Type-1 single-message proof via the + multi-signature binding. Pre-computed signatures can be supplied via the lookup to avoid - redundant signing. Missing signatures are computed on the fly. + redundant signing; missing entries are signed on the fly. The + resulting proofs feed into block production and signature + verification, both of which require real cryptographic content. Args: aggregated_attestations: Attestations with aggregation bitfields set. @@ -563,40 +587,32 @@ def build_attestation_signatures( attestation data then validator index. Returns: - One aggregated signature proof per attestation. + One Type-1 single-message proof per attestation, parallel to the input. """ lookup = signature_lookup or {} - proofs: list[AggregatedSignatureProof] = [] + proofs: list[TypeOneMultiSignature] = [] for agg in aggregated_attestations: - # Decode which validators participated from the bitfield. validator_ids = agg.aggregation_bits.to_validator_indices() - - # Try the lookup first for pre-computed signatures. - # Fall back to signing on the fly for any missing entries. sigs_for_data = lookup.get(agg.data, {}) - # Collect the attestation public key for each participant. public_keys = [self.get_public_keys(vid)[0] for vid in validator_ids] - - # Gather individual signatures, computing any that are missing. signatures = [ sigs_for_data.get(vid) or self.sign_attestation_data(vid, agg.data) for vid in validator_ids ] - # Produce a single aggregated proof that the leanVM can verify - # in one pass over all participants. - proof = AggregatedSignatureProof.aggregate( - xmss_participants=agg.aggregation_bits, - children=[], - raw_xmss=list(zip(public_keys, signatures, strict=True)), - message=hash_tree_root(agg.data), - slot=agg.data.slot, + proofs.append( + TypeOneMultiSignature.aggregate( + children=[], + raw_xmss=list(zip(public_keys, signatures, strict=True)), + xmss_participants=agg.aggregation_bits, + message=hash_tree_root(agg.data), + slot=agg.data.slot, + ) ) - proofs.append(proof) - return AttestationSignatures(data=proofs) + return proofs def _generate_single_keypair( diff --git a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py index 07aaf8578..2593cb450 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py +++ b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py @@ -300,7 +300,7 @@ def make_fixture(self) -> Self: case BlockStep(): # Build a complete signed block from the lightweight spec. # The spec contains minimal fields; we fill the rest. - signed_block = step.block.build_signed_block_with_store( + signed_block, store = step.block.build_signed_block_with_store( store, self._block_registry, key_manager, self.lean_env ) 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 e320bb102..4ea077458 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/state_transition.py +++ b/packages/testing/src/consensus_testing/test_fixtures/state_transition.py @@ -10,7 +10,7 @@ from lean_spec.forks.lstar.containers.state import State from lean_spec.forks.lstar.spec import LstarSpec from lean_spec.subspecs.ssz.hash import hash_tree_root -from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof +from lean_spec.subspecs.xmss.aggregation import TypeOneMultiSignature from lean_spec.types import Bytes32, ValidatorIndices from ..keys import XmssKeyManager @@ -250,7 +250,7 @@ def _build_block_from_spec( # Path 3: normal block construction via the spec's builder. else: - aggregated_payloads: dict[AttestationData, set[AggregatedSignatureProof]] = {} + aggregated_payloads: dict[AttestationData, set[TypeOneMultiSignature]] = {} if spec.attestations: aggregated_payloads = StateTransitionTest._build_aggregated_payloads_from_spec( spec.attestations, state, block_registry @@ -304,7 +304,7 @@ def _build_aggregated_payloads_from_spec( attestation_specs: list[AggregatedAttestationSpec], state: State, block_registry: dict[str, Block], - ) -> dict[AttestationData, set[AggregatedSignatureProof]]: + ) -> dict[AttestationData, set[TypeOneMultiSignature]]: """ Build aggregated signature payloads from attestation specifications. @@ -320,7 +320,7 @@ def _build_aggregated_payloads_from_spec( # XMSS keys require precomputation up to the highest slot used. max_slot = max(spec.slot for spec in attestation_specs) key_manager = XmssKeyManager.shared(max_slot=max_slot) - payloads: dict[AttestationData, set[AggregatedSignatureProof]] = {} + payloads: dict[AttestationData, set[TypeOneMultiSignature]] = {} for spec in attestation_specs: if not spec.valid_signature: diff --git a/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py b/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py index 1f1702b93..d290e9bd3 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py +++ b/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py @@ -7,16 +7,12 @@ from pydantic import Field from lean_spec.forks.lstar.containers.attestation import AggregatedAttestation -from lean_spec.forks.lstar.containers.block import ( - SignedBlock, -) -from lean_spec.forks.lstar.containers.block.types import ( - AggregatedAttestations, - AttestationSignatures, -) +from lean_spec.forks.lstar.containers.block import SignedBlock +from lean_spec.forks.lstar.containers.block.types import AggregatedAttestations from lean_spec.forks.lstar.containers.state import State from lean_spec.forks.lstar.spec import LstarSpec -from lean_spec.types import AggregationBits, Boolean, ValidatorIndex +from lean_spec.subspecs.xmss.aggregation import TypeOneInfos, TypeTwoMultiSignature +from lean_spec.types import AggregationBits, Boolean, ByteListMiB, ValidatorIndex from ..keys import XmssKeyManager from ..test_types import BlockSpec @@ -60,10 +56,9 @@ class VerifySignaturesTest(BaseConsensusFixture): Supported operations: - - `{"operation": "drop_last_signature"}`: Remove the last entry - from the block's attestation_signatures list. Produces a signed - block whose signature-group count is one less than its - attestation count. + - `{"operation": "drop_last_signature"}`: Drop the last info entry from + the merged Type-2 proof while leaving the body unchanged. Produces a + signed block whose proof binds to fewer messages than expected. - `{"operation": "set_proposer_index", "value": int}`: Rewrite the block's proposer_index field. Use this to exercise the validator-bounds check that the builder skips because its round- @@ -154,14 +149,16 @@ def _apply_tamper(self, signed_block: SignedBlock) -> SignedBlock: operation = self.tamper.get("operation") if operation == "drop_last_signature": - original = signed_block.signature.attestation_signatures.data - if not original: - raise ValueError("drop_last_signature requires at least one attestation signature") - truncated = AttestationSignatures(data=list(original[:-1])) - tampered_signatures = signed_block.signature.model_copy( - update={"attestation_signatures": truncated} + decoded = TypeTwoMultiSignature.decode_bytes(signed_block.proof.data) + if len(decoded.info) <= 1: + raise ValueError( + "drop_last_signature requires the proof to bind at least two messages" + ) + truncated_info = TypeOneInfos(data=list(decoded.info)[:-1]) + tampered = decoded.model_copy(update={"info": truncated_info}) + return signed_block.model_copy( + update={"proof": ByteListMiB(data=tampered.encode_bytes())} ) - return signed_block.model_copy(update={"signature": tampered_signatures}) if operation == "set_proposer_index": value = self.tamper.get("value") 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 bd6d885e8..73685cb02 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 @@ -6,7 +6,7 @@ from lean_spec.forks.lstar.containers.block.block import Block from lean_spec.forks.lstar.containers.block.types import AggregatedAttestations from lean_spec.forks.lstar.containers.state import State -from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof +from lean_spec.subspecs.xmss.aggregation import TypeOneInfo, TypeOneMultiSignature from lean_spec.types import ( ByteListMiB, Bytes32, @@ -164,7 +164,7 @@ def build_invalid_proof( state: State, key_manager: XmssKeyManager, block: Block, - ) -> tuple[Block, AggregatedSignatureProof]: + ) -> tuple[Block, TypeOneMultiSignature]: """ Build an invalid attestation proof and append it to the block body. @@ -190,23 +190,26 @@ def build_invalid_proof( data=attestation_data, ) + # Empty proof bytes flag "no real Type-1 here" — the caller treats + # any such entry as a placeholder and bypasses real binding merges. + placeholder = ByteListMiB(data=b"") + if not self.valid_signature: - # Cryptographically invalid proof (zeroed-out bytes). - invalid_proof = AggregatedSignatureProof( - participants=ValidatorIndices(data=self.validator_ids).to_aggregation_bits(), - proof_data=ByteListMiB(data=b"\x00" * 32), + invalid_proof = TypeOneMultiSignature( + info=TypeOneInfo(participants=aggregation_bits, proof=placeholder), + proof=placeholder, ) elif self.signer_ids is not None: # Valid proof from wrong validators (participant mismatch). valid_proof = key_manager.sign_and_aggregate(self.signer_ids, attestation_data) - invalid_proof = AggregatedSignatureProof( - participants=aggregation_bits, - proof_data=valid_proof.proof_data, + invalid_proof = TypeOneMultiSignature( + info=TypeOneInfo(participants=aggregation_bits, proof=valid_proof.proof), + proof=valid_proof.proof, ) else: - invalid_proof = AggregatedSignatureProof( - participants=ValidatorIndices(data=self.validator_ids).to_aggregation_bits(), - proof_data=ByteListMiB(data=b"\x00" * 32), + invalid_proof = TypeOneMultiSignature( + info=TypeOneInfo(participants=aggregation_bits, proof=placeholder), + proof=placeholder, ) # Append invalid attestation to the block body. 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 92b55f075..4616fcd58 100644 --- a/packages/testing/src/consensus_testing/test_types/block_spec.py +++ b/packages/testing/src/consensus_testing/test_types/block_spec.py @@ -10,24 +10,29 @@ AttestationData, SignedAttestation, ) -from lean_spec.forks.lstar.containers.block import ( - Block, - BlockBody, - BlockSignatures, - SignedBlock, -) -from lean_spec.forks.lstar.containers.block.types import ( - AggregatedAttestations, - AttestationSignatures, -) +from lean_spec.forks.lstar.containers.block import Block, BlockBody, SignedBlock +from lean_spec.forks.lstar.containers.block.types import AggregatedAttestations from lean_spec.forks.lstar.containers.state import State from lean_spec.forks.lstar.spec import LstarSpec from lean_spec.forks.lstar.store import Store from lean_spec.subspecs.chain.clock import Interval +from lean_spec.subspecs.chain.config import MAX_ATTESTATIONS_DATA from lean_spec.subspecs.ssz.hash import hash_tree_root -from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof +from lean_spec.subspecs.xmss.aggregation import ( + TypeOneInfo, + TypeOneInfos, + TypeOneMultiSignature, + TypeTwoMultiSignature, +) from lean_spec.subspecs.xmss.containers import Signature -from lean_spec.types import Bytes32, CamelModel, Slot, ValidatorIndex, ValidatorIndices +from lean_spec.types import ( + ByteListMiB, + Bytes32, + CamelModel, + Slot, + ValidatorIndex, + ValidatorIndices, +) from ..keys import XmssKeyManager, create_dummy_signature from .aggregated_attestation_spec import AggregatedAttestationSpec @@ -241,37 +246,111 @@ def build_attestations( def _sign_block( self, final_block: Block, - attestation_proofs: list[AggregatedSignatureProof], + attestation_proofs: list[TypeOneMultiSignature], proposer_index: ValidatorIndex, key_manager: XmssKeyManager, + state: State, ) -> SignedBlock: - """ - Sign a block and assemble the final SignedBlock. + """Sign a block and assemble the final SignedBlock with the merged proof. + + Builds a Type-1 wrapping the proposer's XMSS signature, then merges + that with the per-attestation Type-1 proofs into a single Type-2 proof + and SSZ-encodes it onto the envelope. Consumers of this filler feed + the block through spec.on_block / verify_signatures, which decodes + the proof and verifies it, so an honest merged proof is required. + + When valid_signature is False, the proposer signature is a dummy + XMSS one and the binding-driven aggregation would reject it before + verify_signatures ever runs. The Type-2 envelope is then assembled + directly from the info entries with empty proof bytes — that + decodes structurally and lets verify_signatures reach (and reject + at) the verify_type_2 call, which is the contract the test exercises. Args: final_block: The unsigned block. - attestation_proofs: Aggregated signature proofs for attestations. + attestation_proofs: Per-attestation Type-1 proofs (parallel to + final_block.body.attestations). proposer_index: Which validator proposes this block. key_manager: XMSS key manager for signing. + state: State providing the validator registry used to resolve + participant pubkeys for the merge. Returns: Complete signed block. """ - if self.valid_signature: + # Mirror the consensus-level distinct-attestation cap before + # building the proof envelope; the binding otherwise rejects the + # over-cap merge first and masks the spec-level error message + # such fillers pin. + if len(attestation_proofs) > int(MAX_ATTESTATIONS_DATA): + raise AssertionError( + f"Block contains {len(attestation_proofs)} distinct AttestationData entries; " + f"maximum is {MAX_ATTESTATIONS_DATA}" + ) + + block_root = hash_tree_root(final_block) + proposer_participants = ValidatorIndices(data=[proposer_index]).to_aggregation_bits() + proposer_pubkey = key_manager.get_public_keys(proposer_index)[1] + + # The binding rejects placeholder bytes; if anything in the merged + # input is a dummy (invalid proposer sig or a build_invalid_proof + # attestation), bypass aggregate_type_2 entirely and assemble the + # Type-2 envelope by hand. The result still SSZ-decodes so + # verify_signatures reaches verify_type_2 for the rejection. + any_placeholder_attestation = any(not proof.proof.data for proof in attestation_proofs) + use_placeholder = not self.valid_signature or any_placeholder_attestation + + if not use_placeholder: proposer_signature = key_manager.sign_block_root( proposer_index, self.slot, - hash_tree_root(final_block), + block_root, + ) + proposer_type_1 = TypeOneMultiSignature.aggregate( + children=[], + raw_xmss=[(proposer_pubkey, proposer_signature)], + xmss_participants=proposer_participants, + message=block_root, + slot=self.slot, ) + + public_keys_per_part: list[list] = [ + [ + state.validators[vid].get_attestation_pubkey() + for vid in proof.info.participants.to_validator_indices() + ] + for proof in attestation_proofs + ] + public_keys_per_part.append([proposer_pubkey]) + + merged = TypeTwoMultiSignature.aggregate( + [*attestation_proofs, proposer_type_1], + public_keys_per_part=public_keys_per_part, + ) + proof_bytes = merged.encode_bytes() else: - proposer_signature = create_dummy_signature() + placeholder = ByteListMiB(data=b"") + if not self.valid_signature: + # Burn the dummy signature creation to mirror the legacy + # shape; the merged blob carries no real bytes anyway. + dummy_signature = create_dummy_signature() + del dummy_signature + + proposer_info = TypeOneInfo( + participants=proposer_participants, + proof=placeholder, + ) + envelope = TypeTwoMultiSignature( + info=TypeOneInfos( + data=[*(proof.info for proof in attestation_proofs), proposer_info] + ), + proof=placeholder, + ) + proof_bytes = envelope.encode_bytes() return SignedBlock( block=final_block, - signature=BlockSignatures( - attestation_signatures=AttestationSignatures(data=attestation_proofs), - proposer_signature=proposer_signature, - ), + proof=ByteListMiB(data=proof_bytes), ) def build_signed_block( @@ -353,13 +432,13 @@ def build_signed_block( ) for data, validator_ids in data_to_validator_ids.items() ] - attestation_sigs = key_manager.build_attestation_signatures( + attestation_sigs = key_manager.build_attestation_proofs( AggregatedAttestations(data=aggregated_attestations), signature_lookup=signature_lookup, ) aggregated_payloads = { agg_att.data: {proof} - for agg_att, proof in zip(aggregated_attestations, attestation_sigs.data, strict=True) + for agg_att, proof in zip(aggregated_attestations, attestation_sigs, strict=True) } final_block, _, _, aggregated_signatures = spec.build_block( @@ -379,7 +458,9 @@ def build_signed_block( ) aggregated_signatures.append(invalid_proof) - return self._sign_block(final_block, aggregated_signatures, proposer_index, key_manager) + return self._sign_block( + final_block, aggregated_signatures, proposer_index, key_manager, state + ) def build_signed_block_with_store( self, @@ -387,13 +468,23 @@ def build_signed_block_with_store( block_registry: dict[str, Block], key_manager: XmssKeyManager, lean_env: str, - ) -> SignedBlock: + ) -> tuple[SignedBlock, Store]: """ Build a complete signed block through the Store's attestation pipeline. Simulates what a real node does when proposing a block. Replays the gossip, aggregation, and proposal pipeline through the Store. + Returns a Store enriched with the aggregated Type-1 payloads built + during the simulated pipeline. The caller can persist these so future + block builds can re-aggregate the same attestations rather than + reconstructing them from on-chain block bodies (which would require + splitting the block-level Type-2 proof — a heavy and, in the test + recursive-aggregation mode, unreliable operation). Other fields of + the original Store (gossip signatures, time, head, etc.) are + preserved so the simulated build does not consume state the caller + is tracking separately. + Args: store: Fork choice store for head state lookup and gossip processing. block_registry: Labeled blocks for fork creation. @@ -401,7 +492,7 @@ def build_signed_block_with_store( lean_env: Signature scheme environment name ("test" or "prod"). Returns: - Complete signed block ready for Store processing. + The signed block and the Store with new known payloads merged in. """ spec = LstarSpec() proposer_index = self.resolve_proposer_index(len(store.states[store.head].validators)) @@ -417,6 +508,11 @@ def build_signed_block_with_store( "has no state in store - cannot build on this fork" ) + # Preserve the caller's Store so unrelated fields (gossip signatures, + # head, finalization checkpoints, time) survive the simulated pipeline. + # Only the freshly aggregated Type-1 payloads merge back at the end. + caller_store = store + # Build attestations from this spec's attestation fields. parent_state = store.states[parent_root] _, attestation_signatures, valid_attestations = self.build_attestations( @@ -452,6 +548,9 @@ def build_signed_block_with_store( ) # Trigger Store aggregation to merge gossip signatures into known payloads. + # Aggregation runs on a local clone: gossip pools mutate here, but the + # caller's gossip-signature view must not be consumed by this simulated + # build. Only the freshly aggregated Type-1 payloads propagate back. aggregation_store, _ = spec.aggregate(store) merged_store = spec.accept_new_attestations(aggregation_store) @@ -465,6 +564,13 @@ def build_signed_block_with_store( aggregated_payloads=merged_store.latest_known_aggregated_payloads, ) + # Merge new known payloads (built locally) back into the caller's + # store while leaving every other field untouched. + merged_known = {k: set(v) for k, v in caller_store.latest_known_aggregated_payloads.items()} + for data, proofs in merged_store.latest_known_aggregated_payloads.items(): + merged_known.setdefault(data, set()).update(proofs) + store = caller_store.model_copy(update={"latest_known_aggregated_payloads": merged_known}) + # Append forced attestations that bypass the builder's MAX cap. # Each entry is signed and aggregated so the block carries valid proofs. if self.forced_attestations: @@ -497,4 +603,7 @@ def build_signed_block_with_store( post_state = spec.process_block(post_state, final_block) final_block = final_block.model_copy(update={"state_root": hash_tree_root(post_state)}) - return self._sign_block(final_block, block_proofs, proposer_index, key_manager) + signed_block = self._sign_block( + final_block, block_proofs, proposer_index, key_manager, parent_state + ) + return signed_block, 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 ce452aa6b..9a3f65341 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 @@ -6,7 +6,7 @@ from lean_spec.forks.lstar.containers.attestation.attestation import SignedAggregatedAttestation from lean_spec.forks.lstar.containers.block.block import Block from lean_spec.forks.lstar.containers.state import State -from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof +from lean_spec.subspecs.xmss.aggregation import TypeOneInfo, TypeOneMultiSignature from lean_spec.types import ( ByteListMiB, Bytes32, @@ -188,9 +188,13 @@ def build_signed( # Correct participant bitfield but zeroed-out proof bytes. # Exercises signature verification rejection. if not self.valid_signature: - proof = AggregatedSignatureProof( - participants=ValidatorIndices(data=validator_ids).to_aggregation_bits(), - proof_data=ByteListMiB(data=b"\x00" * 32), + placeholder = ByteListMiB(data=b"\x00" * 32) + proof = TypeOneMultiSignature( + info=TypeOneInfo( + participants=ValidatorIndices(data=validator_ids).to_aggregation_bits(), + proof=placeholder, + ), + proof=placeholder, ) return SignedAggregatedAttestation(data=attestation_data, proof=proof) @@ -205,9 +209,9 @@ def build_signed( # but the claimed participants no longer match. # The store must detect and reject this inconsistency. if self.signer_ids and self.signer_ids != self.validator_ids: - proof = AggregatedSignatureProof( - participants=ValidatorIndices(data=validator_ids).to_aggregation_bits(), - proof_data=proof.proof_data, + tampered_info = proof.info.model_copy( + update={"participants": ValidatorIndices(data=validator_ids).to_aggregation_bits()} ) + proof = proof.model_copy(update={"info": tampered_info}) return SignedAggregatedAttestation(data=attestation_data, proof=proof) diff --git a/packages/testing/src/consensus_testing/test_types/step_types.py b/packages/testing/src/consensus_testing/test_types/step_types.py index ca346bf0d..fa36fe4c1 100644 --- a/packages/testing/src/consensus_testing/test_types/step_types.py +++ b/packages/testing/src/consensus_testing/test_types/step_types.py @@ -118,23 +118,23 @@ def serialize_block(self, value: BlockSpec) -> dict[str, Any]: Parameters: ---------- value : BlockSpec - The BlockSpec field value (ignored, we use _filled_block instead). + Used as a fallback when the step's filled block is unavailable + (e.g. the expected-failure path raised before signing). Returns: ------- dict[str, Any] - The serialized Block. - - Raises: - ------ - ValueError - If _filled_block is None (make_fixture not called yet). + The serialized Block, or the raw spec if the step never produced + a filled block. """ + # Expected-failure steps may bail out before signing; fall back to + # serialising the raw spec so fixture writing still completes. if self._filled_block is None: - raise ValueError( - "Block not filled yet - make_fixture() must be called before serialization. " - "This BlockStep should only be serialized after the fixture has been processed." - ) + result: dict[str, Any] = value.model_dump(mode="json", by_alias=True) + if value.label: + result["blockRootLabel"] = value.label + return result + result = self._filled_block.to_json() if value.label: result["blockRootLabel"] = value.label @@ -186,24 +186,18 @@ def serialize_gossip_attestation(self, value: GossipAttestationSpec) -> dict[str Parameters: ---------- value : GossipAttestationSpec - The spec field value (ignored, we use _filled_attestation instead). + Used as a fallback when the step's filled attestation is + unavailable (e.g. the expected-failure path raised before + signing). Returns: ------- dict[str, Any] - The serialized SignedAttestation. - - Raises: - ------ - ValueError - If _filled_attestation is None (make_fixture not called yet). + The serialized SignedAttestation, or the raw spec if the step + never produced a filled attestation. """ if self._filled_attestation is None: - raise ValueError( - "Attestation not filled yet - make_fixture() must be called " - "before serialization. This AttestationStep should only be " - "serialized after the fixture has been processed." - ) + return value.model_dump(mode="json", by_alias=True) return self._filled_attestation.to_json() @@ -224,11 +218,13 @@ class GossipAggregatedAttestationStep(BaseForkChoiceStep): def serialize_gossip_aggregated_attestation( self, value: GossipAggregatedAttestationSpec ) -> dict[str, Any]: - """Return the filled aggregated attestation for serialization.""" + """Return the filled aggregated attestation for serialization. + + Falls back to the raw spec when the step never produced a filled + attestation (e.g. expected-failure flows that raised early). + """ if self._filled_attestation is None: - raise ValueError( - "Aggregated attestation not filled yet - make_fixture() must process the step." - ) + return value.model_dump(mode="json", by_alias=True) return self._filled_attestation.to_json() diff --git a/pyproject.toml b/pyproject.toml index 208630ff1..58efff512 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ requires-python = ">=3.12" dependencies = [ "pydantic>=2.12.0,<3", "typing-extensions>=4.4", - "lean-multisig-py>=0.0.1", + "lean-multisig-py>=0.0.2", "httpx>=0.28.0,<1", "aiohttp>=3.11.0,<4", "cryptography>=46.0.0", @@ -72,6 +72,7 @@ ignore = ["D205", "D203", "D212", "D415", "C901", "A005", "C420"] convention = "google" [tool.ruff.lint.isort] +combine-as-imports = true force-single-line = false known-first-party = ["lean_spec"] @@ -136,7 +137,7 @@ members = ["packages/*"] [tool.uv.sources] lean-ethereum-testing = { workspace = true } -lean-multisig-py = { git = "https://github.com/anshalshukla/leanMultisig-py", branch = "devnet4" } +lean-multisig-py = { git = "https://github.com/anshalshukla/leanMultisig-py", tag = "v0.0.4" } [dependency-groups] test = [ @@ -147,7 +148,7 @@ test = [ "pytest-timeout>=2.2.0,<3", "hypothesis>=6.138.14", "lean-ethereum-testing", - "lean-multisig-py>=0.0.1", + "lean-multisig-py>=0.0.2", "pycryptodome>=3.20.0,<4", ] lint = [ diff --git a/src/lean_spec/__main__.py b/src/lean_spec/__main__.py index 57577cc9b..9465e13b4 100644 --- a/src/lean_spec/__main__.py +++ b/src/lean_spec/__main__.py @@ -43,8 +43,7 @@ from lean_spec.subspecs.api import ApiServerConfig from lean_spec.subspecs.chain.config import ATTESTATION_COMMITTEE_COUNT from lean_spec.subspecs.genesis import GenesisConfig -from lean_spec.subspecs.metrics import PrometheusObserver -from lean_spec.subspecs.metrics import registry as metrics +from lean_spec.subspecs.metrics import PrometheusObserver, registry as metrics from lean_spec.subspecs.networking.client import LiveNetworkEventSource from lean_spec.subspecs.networking.enr import ENR from lean_spec.subspecs.networking.gossipsub import GossipTopic diff --git a/src/lean_spec/forks/__init__.py b/src/lean_spec/forks/__init__.py index 5a1178af9..36d71d1bf 100644 --- a/src/lean_spec/forks/__init__.py +++ b/src/lean_spec/forks/__init__.py @@ -15,8 +15,7 @@ SignedBlock, Validator, ) -from .lstar.containers.block import BlockSignatures -from .lstar.containers.block.types import AggregatedAttestations, AttestationSignatures +from .lstar.containers.block.types import AggregatedAttestations from .lstar.containers.state import State, Validators from .lstar.spec import LstarSpec, LstarStore from .lstar.store import AttestationSignatureEntry @@ -38,11 +37,9 @@ "Attestation", "AttestationData", "AttestationSignatureEntry", - "AttestationSignatures", "Block", "BlockBody", "BlockHeader", - "BlockSignatures", "Config", "DEFAULT_REGISTRY", "FORK_SEQUENCE", diff --git a/src/lean_spec/forks/lstar/containers/attestation/attestation.py b/src/lean_spec/forks/lstar/containers/attestation/attestation.py index 959fde36f..13ff4c0d1 100644 --- a/src/lean_spec/forks/lstar/containers/attestation/attestation.py +++ b/src/lean_spec/forks/lstar/containers/attestation/attestation.py @@ -13,7 +13,7 @@ from __future__ import annotations -from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof +from lean_spec.subspecs.xmss.aggregation import TypeOneMultiSignature from lean_spec.subspecs.xmss.containers import Signature from lean_spec.types import AggregationBits, Checkpoint, Container, Slot, ValidatorIndex @@ -75,5 +75,5 @@ class SignedAggregatedAttestation(Container): data: AttestationData """Combined attestation data similar to the beacon chain format.""" - proof: AggregatedSignatureProof - """Aggregated signature proof covering all participating validators.""" + proof: TypeOneMultiSignature + """Aggregated single-message proof covering all participating validators.""" diff --git a/src/lean_spec/forks/lstar/containers/block/__init__.py b/src/lean_spec/forks/lstar/containers/block/__init__.py index 3bce45ae9..6e60510a5 100644 --- a/src/lean_spec/forks/lstar/containers/block/__init__.py +++ b/src/lean_spec/forks/lstar/containers/block/__init__.py @@ -4,20 +4,14 @@ Block, BlockBody, BlockHeader, - BlockSignatures, SignedBlock, ) -from .types import ( - AggregatedAttestations, - AttestationSignatures, -) +from .types import AggregatedAttestations __all__ = [ "Block", "BlockBody", "BlockHeader", - "BlockSignatures", "SignedBlock", "AggregatedAttestations", - "AttestationSignatures", ] diff --git a/src/lean_spec/forks/lstar/containers/block/block.py b/src/lean_spec/forks/lstar/containers/block/block.py index d2e4d617f..0cb04c106 100644 --- a/src/lean_spec/forks/lstar/containers/block/block.py +++ b/src/lean_spec/forks/lstar/containers/block/block.py @@ -6,21 +6,17 @@ The proposer is determined by slot assignment. """ -from lean_spec.subspecs.xmss.containers import Signature -from lean_spec.types import Bytes32, Slot, ValidatorIndex +from lean_spec.types import ByteListMiB, Bytes32, Slot, ValidatorIndex from lean_spec.types.container import Container -from .types import ( - AggregatedAttestations, - AttestationSignatures, -) +from .types import AggregatedAttestations class BlockBody(Container): """Payload of a block containing attestations.""" attestations: AggregatedAttestations - """Attestations in the block. Signatures are in BlockSignatures.""" + """Attestations in the block. Signatures are folded into the block-level proof.""" class BlockHeader(Container): @@ -66,21 +62,16 @@ class Block(Container): """The block's payload.""" -class BlockSignatures(Container): - """Aggregated signature payload for a block.""" - - attestation_signatures: AttestationSignatures - """Aggregated signatures for attestations in the block body.""" - - proposer_signature: Signature - """Signature over the block root using the proposer's proposal key.""" - - class SignedBlock(Container): - """Envelope carrying a block and its aggregated signatures.""" + """Envelope carrying a block with a single aggregated proof for all signatures. + + The proof is the SSZ-encoded form of a Type-2 multi-message proof that + binds every attestation in the body plus the proposer's signature over + the block root. + """ block: Block """The block being signed.""" - signature: BlockSignatures - """Aggregated signature payload for the block.""" + proof: ByteListMiB + """Single full-block proof covering attestations and the proposer signature.""" diff --git a/src/lean_spec/forks/lstar/containers/block/types.py b/src/lean_spec/forks/lstar/containers/block/types.py index 670e8c232..b456681e2 100644 --- a/src/lean_spec/forks/lstar/containers/block/types.py +++ b/src/lean_spec/forks/lstar/containers/block/types.py @@ -3,7 +3,6 @@ from __future__ import annotations from lean_spec.subspecs.chain.config import VALIDATOR_REGISTRY_LIMIT -from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof from lean_spec.types import SSZList from ..attestation import AggregatedAttestation @@ -13,17 +12,3 @@ class AggregatedAttestations(SSZList[AggregatedAttestation]): """List of aggregated attestations included in a block.""" LIMIT = int(VALIDATOR_REGISTRY_LIMIT) - - -class AttestationSignatures(SSZList[AggregatedSignatureProof]): - """ - List of per-attestation aggregated signature proofs. - - Each entry corresponds to an aggregated attestation from the block body. - - It contains: - - the participants bitfield, - - proof bytes from leanVM signature aggregation. - """ - - LIMIT = int(VALIDATOR_REGISTRY_LIMIT) diff --git a/src/lean_spec/forks/lstar/spec.py b/src/lean_spec/forks/lstar/spec.py index 3701bc308..c7471ca88 100644 --- a/src/lean_spec/forks/lstar/spec.py +++ b/src/lean_spec/forks/lstar/spec.py @@ -1,8 +1,7 @@ """Lstar fork — identity and construction facade.""" from collections import defaultdict -from collections.abc import Iterable -from collections.abc import Set as AbstractSet +from collections.abc import Iterable, Set as AbstractSet from typing import Any, ClassVar from lean_spec.forks.lstar.containers import ( @@ -18,11 +17,7 @@ SignedBlock, Validator, ) -from lean_spec.forks.lstar.containers.block.block import BlockSignatures -from lean_spec.forks.lstar.containers.block.types import ( - AggregatedAttestations, - AttestationSignatures, -) +from lean_spec.forks.lstar.containers.block.types import AggregatedAttestations from lean_spec.forks.lstar.containers.state import State from lean_spec.forks.lstar.containers.state.types import ( HistoricalBlockHashes, @@ -44,16 +39,22 @@ observe_state_transition, ) from lean_spec.subspecs.ssz.hash import hash_tree_root -from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof, AggregationError +from lean_spec.subspecs.xmss.aggregation import ( + AggregationError, + TypeOneInfo, + TypeOneMultiSignature, + TypeTwoMultiSignature, +) +from lean_spec.subspecs.xmss.containers import PublicKey from lean_spec.subspecs.xmss.interface import TARGET_SIGNATURE_SCHEME, GeneralizedXmssScheme from lean_spec.types import ( ZERO_HASH, Boolean, + ByteListMiB, Bytes32, Checkpoint, Slot, SSZList, - Uint8, Uint64, ValidatorIndex, ValidatorIndices, @@ -83,9 +84,7 @@ class LstarSpec(ForkProtocol): block_body_class: type[BlockBody] = BlockBody block_header_class: type[BlockHeader] = BlockHeader signed_block_class: type[SignedBlock] = SignedBlock - block_signatures_class: type[BlockSignatures] = BlockSignatures aggregated_attestations_class: type[AggregatedAttestations] = AggregatedAttestations - attestation_signatures_class: type[AttestationSignatures] = AttestationSignatures store_class: type[Store[State, Block]] = LstarStore attestation_data_class: type[AttestationData] = AttestationData @@ -640,8 +639,8 @@ def build_block( proposer_index: ValidatorIndex, parent_root: Bytes32, known_block_roots: AbstractSet[Bytes32], - aggregated_payloads: dict[AttestationData, set[AggregatedSignatureProof]] | None = None, - ) -> tuple[Block, State, list[AggregatedAttestation], list[AggregatedSignatureProof]]: + aggregated_payloads: dict[AttestationData, set[TypeOneMultiSignature]] | None = None, + ) -> tuple[Block, State, list[AggregatedAttestation], list[TypeOneMultiSignature]]: """ Build a valid block on top of the given pre-state. @@ -653,7 +652,7 @@ def build_block( repeats with the new checkpoint. """ aggregated_attestations: list[AggregatedAttestation] = [] - aggregated_signatures: list[AggregatedSignatureProof] = [] + aggregated_signatures: list[TypeOneMultiSignature] = [] if aggregated_payloads: # Fixed-point loop: find attestation_data entries matching the current @@ -675,8 +674,12 @@ def build_block( for att_data, proofs in sorted( aggregated_payloads.items(), key=lambda item: item[0].target.slot ): + # Stop adding new attestations once we hit the per-block + # cap. The proposer signature is merged as an extra + # component into the same Type-2 envelope, so total + # components stay at MAX_ATTESTATIONS_DATA + 1. if ( - Uint8(len(processed_att_data)) >= MAX_ATTESTATIONS_DATA + len(processed_att_data) >= int(MAX_ATTESTATIONS_DATA) and att_data not in processed_att_data ): break @@ -689,16 +692,25 @@ def build_block( if att_data in processed_att_data: continue + + # Placeholders carry only participant info for + # fork-choice extraction. They cannot drive a real + # Type-2 merge, so skip att_data entries whose only + # cached proofs are empty-bytes placeholders. + real_proofs = {p for p in proofs if p.proof.data} + if not real_proofs: + continue + processed_att_data.add(att_data) found_entries = True - selected, _ = AggregatedSignatureProof.select_greedily(proofs) + selected, _ = TypeOneMultiSignature.select_greedily(real_proofs) aggregated_signatures.extend(selected) for proof in selected: aggregated_attestations.append( self.aggregated_attestation_class( - aggregation_bits=proof.participants, + aggregation_bits=proof.info.participants, data=att_data, ) ) @@ -732,7 +744,7 @@ def build_block( # During the fixed-point loop above, multiple proofs may have been # selected for the same AttestationData across iterations. Group them # and merge each group into a single recursive proof. - proof_groups: dict[AttestationData, list[AggregatedSignatureProof]] = {} + proof_groups: dict[AttestationData, list[TypeOneMultiSignature]] = {} for att, sig in zip(aggregated_attestations, aggregated_signatures, strict=True): proof_groups.setdefault(att.data, []).append(sig) @@ -745,27 +757,25 @@ def build_block( # Multiple proofs for the same data were aggregated separately. # Merge them into one recursive proof using children-only # aggregation (no new raw signatures). - children = [ - ( - proof, - [ - state.validators[vid].get_attestation_pubkey() - for vid in proof.participants.to_validator_indices() - ], - ) - for proof in proofs - ] - sig = AggregatedSignatureProof.aggregate( - xmss_participants=None, - children=children, + sig = TypeOneMultiSignature.aggregate( + children=[ + proof.with_public_keys( + [ + state.validators[vid].get_attestation_pubkey() + for vid in proof.info.participants.to_validator_indices() + ] + ) + for proof in proofs + ], raw_xmss=[], + xmss_participants=None, message=hash_tree_root(att_data), slot=att_data.slot, ) aggregated_signatures.append(sig) aggregated_attestations.append( self.aggregated_attestation_class( - aggregation_bits=sig.participants, data=att_data + aggregation_bits=sig.info.participants, data=att_data ) ) @@ -792,88 +802,76 @@ def verify_signatures( validators: Validators, ) -> bool: """ - Verify all XMSS signatures in this signed block. + Verify the merged Type-2 proof carried by a signed block. - Checks that: + The block envelope holds one SSZ-encoded Type-2 proof binding: - - Each body attestation is signed by participating validators - - The proposer signed the block root with the proposal key + - Each aggregated attestation in the body to its participants. + - The proposer's signature over the block root. The signing scheme is read from this fork's capability. Args: - signed_block: The signed block whose signatures are checked. + signed_block: The signed block whose merged proof is checked. validators: Validator registry providing public keys for verification. Returns: - True if all signatures are valid. + True if the merged proof is valid. Raises: - AssertionError: On verification failure. + AssertionError: On any structural or cryptographic mismatch. """ block = signed_block.block - signatures = signed_block.signature aggregated_attestations = block.body.attestations - attestation_signatures = signatures.attestation_signatures - # Each attestation in the body must have a corresponding signature entry. - assert len(aggregated_attestations) == len(attestation_signatures), ( - "Attestation signature groups must align with block body attestations" + try: + type_two = TypeTwoMultiSignature.decode_bytes(signed_block.proof.data) + except Exception as exc: + raise AssertionError(f"Block proof decoding failed: {exc}") from exc + + expected_count = len(aggregated_attestations) + 1 + assert len(type_two.info) == expected_count, ( + f"Block proof binds to {len(type_two.info)} messages, " + f"expected {expected_count} (one per attestation + proposer)" ) - # Attestations and signatures are parallel arrays. - # - Each attestation says "validators X, Y, Z voted for this data". - # - Each signature proves those validators actually signed. - for aggregated_attestation, aggregated_signature in zip( - aggregated_attestations, attestation_signatures, strict=True - ): - # Extract which validators participated in this attestation. - # The aggregation bits encode validator indices as a bitfield. - validator_ids = aggregated_attestation.aggregation_bits.to_validator_indices() - - # The signed message is the attestation data root. - # All validators in this group signed this exact data. - attestation_data_root = hash_tree_root(aggregated_attestation.data) + num_validators = Uint64(len(validators)) + public_keys_per_message: list[list[PublicKey]] = [] + # Attestation entries: parallel to block.body.attestations. + # Message and slot live on the block body, not on the proof envelope — + # the Type-2 binding rejects anything that doesn't match what was + # signed, so we only cross-check the participant bitfield here. + for idx, aggregated_attestation in enumerate(aggregated_attestations): + validator_ids = aggregated_attestation.aggregation_bits.to_validator_indices() for validator_id in validator_ids: - num_validators = Uint64(len(validators)) assert validator_id.is_valid(num_validators), "Validator index out of range" - # Collect attestation public keys for all participating validators. - # Order matters: must match the order in the aggregated signature. - public_keys = [validators[vid].get_attestation_pubkey() for vid in validator_ids] + info = type_two.info[idx] + assert info.participants == aggregated_attestation.aggregation_bits, ( + f"Block proof entry {idx} participants must match aggregation bits" + ) - try: - aggregated_signature.verify( - public_keys=public_keys, - message=attestation_data_root, - slot=aggregated_attestation.data.slot, - ) - except AggregationError as exc: - raise AssertionError( - f"Attestation aggregated signature verification failed: {exc}" - ) from exc + public_keys_per_message.append( + [validators[vid].get_attestation_pubkey() for vid in validator_ids] + ) - # Verify the proposer's signature over the block root. - # - # The proposer signs hash_tree_root(block) with their proposal key. - # This proves the proposer endorsed this specific block. + # Proposer entry: bound to block root with a singleton participant set. proposer_index = block.proposer_index - assert proposer_index.is_valid(Uint64(len(validators))), "Proposer index out of range" + assert proposer_index.is_valid(num_validators), "Proposer index out of range" + + proposer_info = type_two.info[len(aggregated_attestations)] + expected_proposer_bits = ValidatorIndices(data=[proposer_index]).to_aggregation_bits() + assert proposer_info.participants == expected_proposer_bits, ( + "Block proof proposer entry participants must encode the proposer index" + ) - proposer = validators[proposer_index] - block_root = hash_tree_root(block) + public_keys_per_message.append([validators[proposer_index].get_proposal_pubkey()]) try: - valid = self.sig_scheme.verify( - proposer.get_proposal_pubkey(), - block.slot, - block_root, - signatures.proposer_signature, - ) - except (ValueError, IndexError): - valid = False - assert valid, "Proposer block signature verification failed" + type_two.verify(public_keys_per_message=public_keys_per_message) + except AggregationError as exc: + raise AssertionError(f"Block proof verification failed: {exc}") from exc return True @@ -1123,8 +1121,12 @@ def on_gossip_aggregated_attestation( self.validate_attestation(store, data) + # The proof envelope no longer carries the signed message and slot — + # they are supplied to verify() from the advertised AttestationData + # below. The binding rejects any mismatch, which is what would have + # been pinned by a structural assert here. # Get validator IDs who participated in this aggregation - validator_ids = proof.participants.to_validator_indices() + validator_ids = proof.info.participants.to_validator_indices() # Retrieve the relevant state to look up public keys for verification. key_state = store.states.get(data.target.root) @@ -1143,7 +1145,7 @@ def on_gossip_aggregated_attestation( # Prepare public keys for verification public_keys = [validators[vid].get_attestation_pubkey() for vid in validator_ids] - # Verify the leanVM aggregated proof + # Verify the Type-1 single-message aggregated proof. try: proof.verify( public_keys=public_keys, @@ -1235,41 +1237,44 @@ def on_block( } ) - # Process block body attestations and their signatures - # Block attestations go directly to "known" payloads + # The block body still constrains how many distinct AttestationData + # entries it may carry. aggregated_attestations = block.body.attestations - attestation_signatures = signed_block.signature.attestation_signatures - - assert len(aggregated_attestations) == len(attestation_signatures), ( - "Attestation signature groups must match aggregated attestations" - ) - - # Each unique AttestationData must appear at most once per block. att_data_set = {att.data for att in aggregated_attestations} assert len(att_data_set) == len(aggregated_attestations), ( "Block contains duplicate AttestationData entries; " "each AttestationData must appear at most once" ) - assert Uint8(len(att_data_set)) <= MAX_ATTESTATIONS_DATA, ( + # +1 accounts for the proposer signature component, which shares + # the same Type-2 proof envelope as the per-attestation Type-1s. + assert len(att_data_set) <= int(MAX_ATTESTATIONS_DATA) + 1, ( f"Block contains {len(att_data_set)} distinct AttestationData entries; " f"maximum is {MAX_ATTESTATIONS_DATA}" ) - # Copy the aggregated proof map for updates - # Shallow-copy the dict and its inner sets to preserve immutability - # Block attestations go directly to "known" payloads - # (like is_from_block=True in the spec) - block_proofs: dict[AttestationData, set[AggregatedSignatureProof]] = { + # Block-included attestations must contribute to fork choice + # weights. The block-level Type-2 proof has already been + # verified as a whole; the body's aggregation bits are + # therefore trustworthy. Synthesize a placeholder Type-1 per + # AttestationData (real info, empty proof bytes) so the + # forkchoice extractor can read participants without going + # through a binding-driven split-by-message. + block_proofs: dict[AttestationData, set[TypeOneMultiSignature]] = { k: set(v) for k, v in store.latest_known_aggregated_payloads.items() } + for aggregated_attestation in aggregated_attestations: + synthesized = TypeOneMultiSignature( + info=TypeOneInfo( + participants=aggregated_attestation.aggregation_bits, + proof=ByteListMiB(data=b""), + ), + proof=ByteListMiB(data=b""), + ) + block_proofs.setdefault(aggregated_attestation.data, set()).add(synthesized) - for att, proof in zip(aggregated_attestations, attestation_signatures, strict=True): - block_proofs.setdefault(att.data, set()).add(proof) - - # Update store with new aggregated proofs and attestation data store = store.model_copy(update={"latest_known_aggregated_payloads": block_proofs}) - # Update forkchoice head based on new block and attestations + # Update forkchoice head with the new block plus its attestations. store = self.update_head(store) # Prune stale attestation data when finalization advances @@ -1281,7 +1286,7 @@ def on_block( def extract_attestations_from_aggregated_payloads( self, store: LstarStore, - aggregated_payloads: dict[AttestationData, set[AggregatedSignatureProof]], + aggregated_payloads: dict[AttestationData, set[TypeOneMultiSignature]], ) -> dict[ValidatorIndex, AttestationData]: """Extract attestations from aggregated payloads. @@ -1292,7 +1297,7 @@ def extract_attestations_from_aggregated_payloads( for attestation_data, proofs in aggregated_payloads.items(): for proof in proofs: - for validator_id in proof.participants.to_validator_indices(): + for validator_id in proof.info.participants.to_validator_indices(): existing = attestations.get(validator_id) if existing is None or existing.slot < attestation_data.slot: attestations[validator_id] = attestation_data @@ -1591,9 +1596,13 @@ def aggregate(self, store: LstarStore) -> tuple[LstarStore, list[SignedAggregate # # New payloads go first because they represent uncommitted # work — known payloads fill remaining gaps. - child_proofs, covered = AggregatedSignatureProof.select_greedily( - new.get(data), known.get(data) - ) + + # Empty-bytes placeholders carry only participant info for + # fork-choice extraction. They cannot serve as child proofs + # in a real Type-1 aggregation, so drop them before selecting. + new_real = {p for p in (new.get(data) or set()) if p.proof.data} or None + known_real = {p for p in (known.get(data) or set()) if p.proof.data} or None + child_proofs, covered = TypeOneMultiSignature.select_greedily(new_real, known_real) # Phase 2: Fill # @@ -1612,43 +1621,41 @@ def aggregate(self, store: LstarStore) -> tuple[LstarStore, list[SignedAggregate if e.validator_id not in covered ] - # The XMSS layer enforces a minimum: either at least one raw - # signature, or at least two child proofs to merge. + # The aggregation layer enforces a minimum: either at least one + # raw signature, or at least two child proofs to merge. # # A lone child proof is already a valid proof — nothing to do. if not raw_entries and len(child_proofs) < 2: continue - # Encode the set of raw signers as a compact bitfield. - xmss_participants = ValidatorIndices( - data=[vid for vid, _, _ in raw_entries] - ).to_aggregation_bits() - raw_xmss = [(pk, sig) for _, pk, sig in raw_entries] + # Encode raw signers as a compact bitfield when present. + # Child-only aggregation (no raw signatures) must pass None. + if raw_entries: + xmss_participants = ValidatorIndices( + data=[vid for vid, _, _ in raw_entries] + ).to_aggregation_bits() + raw_xmss = [(pk, sig) for _, pk, sig in raw_entries] + else: + xmss_participants = None + raw_xmss = [] # Phase 3: Aggregate # - # Build the recursive proof tree. - # - # Each child proof needs its participants' public keys so - # the XMSS prover can verify inner proofs while constructing - # the outer one. - children = [ - ( - child, - [ - validators[vid].get_attestation_pubkey() - for vid in child.participants.to_validator_indices() - ], - ) - for child in child_proofs - ] - - # Hand everything to the XMSS subspec. - # Out comes a single proof covering all selected validators. - proof = AggregatedSignatureProof.aggregate( - xmss_participants=xmss_participants, - children=children, + # Build the recursive proof tree. Each child proof carries its + # own participant bitfield; the binding will resolve those to + # pubkeys against the registry when verifying inner proofs. + proof = TypeOneMultiSignature.aggregate( + children=[ + child.with_public_keys( + [ + validators[vid].get_attestation_pubkey() + for vid in child.info.participants.to_validator_indices() + ] + ) + for child in child_proofs + ], raw_xmss=raw_xmss, + xmss_participants=xmss_participants, message=hash_tree_root(data), slot=data.slot, ) @@ -1658,7 +1665,7 @@ def aggregate(self, store: LstarStore) -> tuple[LstarStore, list[SignedAggregate # # Record freshly produced proofs so future rounds can reuse them. # Remove gossip signatures that were consumed by this aggregation. - new_aggregated_payloads: dict[AttestationData, set[AggregatedSignatureProof]] = {} + new_aggregated_payloads: dict[AttestationData, set[TypeOneMultiSignature]] = {} for signed_att in new_aggregates: new_aggregated_payloads.setdefault(signed_att.data, set()).add(signed_att.proof) @@ -1816,8 +1823,8 @@ def produce_block_with_signatures( store: LstarStore, slot: Slot, validator_index: ValidatorIndex, - ) -> tuple[LstarStore, Block, list[AggregatedSignatureProof]]: - """Produce a block and its aggregated signature proofs for the target slot. + ) -> tuple[LstarStore, Block, list[TypeOneMultiSignature]]: + """Produce a block and its per-attestation Type-1 proofs for the target slot. Block production proceeds in four stages: 1. Retrieve the current chain head as the parent block @@ -1828,6 +1835,11 @@ def produce_block_with_signatures( The block builder uses a fixed-point algorithm to collect attestations. Each iteration may update the justified checkpoint. + Returns the per-attestation Type-1 proofs unmerged. The validator + service signs the block root with the proposal key, wraps that into + a singleton Type-1, and merges all of them into the block-level + Type-2 proof carried by SignedBlock.proof. + Raises: AssertionError: If validator is not the proposer for this slot, or if the produced block fails to close a justified divergence diff --git a/src/lean_spec/forks/lstar/store.py b/src/lean_spec/forks/lstar/store.py index 4d96ba6b5..6e9a6a404 100644 --- a/src/lean_spec/forks/lstar/store.py +++ b/src/lean_spec/forks/lstar/store.py @@ -15,7 +15,7 @@ Config, ) from lean_spec.subspecs.chain.clock import Interval -from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof +from lean_spec.subspecs.xmss.aggregation import TypeOneMultiSignature from lean_spec.subspecs.xmss.containers import Signature from lean_spec.types import Bytes32, Checkpoint, ValidatorIndex from lean_spec.types.base import StrictBaseModel @@ -128,7 +128,7 @@ class Store(StrictBaseModel, Generic[StateT, BlockT]): Keyed by AttestationData. """ - latest_new_aggregated_payloads: dict[AttestationData, set[AggregatedSignatureProof]] = Field( + latest_new_aggregated_payloads: dict[AttestationData, set[TypeOneMultiSignature]] = Field( default_factory=dict ) """ @@ -136,10 +136,14 @@ class Store(StrictBaseModel, Generic[StateT, BlockT]): These payloads are "new" and do not yet contribute to fork choice. They migrate to known payloads via interval ticks. - Populated from blocks or gossip aggregated attestations. + Populated from gossip aggregated attestations only; blocks no longer + feed individual proofs into this map (the block-level proof is a + merged Type-2 blob that the spec verifies as a whole). Aggregators will + still extract the individual per-message proofs from the block-level + Type-2 and gossip aggregated attestations. """ - latest_known_aggregated_payloads: dict[AttestationData, set[AggregatedSignatureProof]] = Field( + latest_known_aggregated_payloads: dict[AttestationData, set[TypeOneMultiSignature]] = Field( default_factory=dict ) """ diff --git a/src/lean_spec/forks/protocol.py b/src/lean_spec/forks/protocol.py index 81722ec17..9bc223b97 100644 --- a/src/lean_spec/forks/protocol.py +++ b/src/lean_spec/forks/protocol.py @@ -90,20 +90,13 @@ class SpecAggregatedAttestationsType(SpecSSZType, Protocol): """ -class SpecAttestationSignaturesType(SpecSSZType, Protocol): - """Structural contract: any fork's AttestationSignatures list class. - - Bounded SSZ list of aggregated signature proofs aligned one-for-one - with a block body's aggregated attestations. - """ - - class SpecSignedBlockType(SpecSSZType, Protocol): """Structural contract: any fork's SignedBlock container class. - A SignedBlock wraps a Block with its proposer + attestation signatures. - Subspecs treat instances as opaque SSZ-encodable payloads passed - between sync, gossip, and storage. + A SignedBlock wraps a Block with a single aggregated proof covering + every attestation in the body plus the proposer's signature over + the block root. Subspecs treat instances as opaque SSZ-encodable + payloads passed between sync, gossip, and storage. """ @property @@ -112,13 +105,6 @@ def block(self) -> SpecBlockType: ... -class SpecBlockSignaturesType(SpecSSZType, Protocol): - """Structural contract: any fork's BlockSignatures container class. - - Carries the proposer and attestation signature bundle for a block. - """ - - class SpecAttestationDataType(SpecSSZType, Protocol): """Structural contract: any fork's AttestationData container class. @@ -316,17 +302,11 @@ class ForkProtocol(ABC): """Concrete BlockHeader container class owned by this fork.""" signed_block_class: type[SpecSignedBlockType] - """Concrete SignedBlock container class — block + signatures envelope.""" - - block_signatures_class: type[SpecBlockSignaturesType] - """Concrete BlockSignatures container class — proposer + attestation signatures.""" + """Concrete SignedBlock container class — block + merged proof envelope.""" aggregated_attestations_class: type[SpecAggregatedAttestationsType] """Concrete AggregatedAttestations list class — block-body aggregated votes.""" - attestation_signatures_class: type[SpecAttestationSignaturesType] - """Concrete AttestationSignatures list class — signature group bundle.""" - store_class: type[SpecStoreType] """Concrete forkchoice Store class owned by this fork.""" diff --git a/src/lean_spec/snappy/framing.py b/src/lean_spec/snappy/framing.py index 91a10501f..b0592bb5a 100644 --- a/src/lean_spec/snappy/framing.py +++ b/src/lean_spec/snappy/framing.py @@ -90,8 +90,7 @@ from typing import Final from .compress import compress as raw_compress -from .decompress import SnappyDecompressionError -from .decompress import decompress as raw_decompress +from .decompress import SnappyDecompressionError, decompress as raw_decompress STREAM_IDENTIFIER: Final = b"\xff\x06\x00\x00sNaPpY" """Stream identifier marking the start of a Snappy framed stream. diff --git a/src/lean_spec/subspecs/chain/config.py b/src/lean_spec/subspecs/chain/config.py index f22123efa..c2d5b4421 100644 --- a/src/lean_spec/subspecs/chain/config.py +++ b/src/lean_spec/subspecs/chain/config.py @@ -53,5 +53,5 @@ ATTESTATION_COMMITTEE_COUNT: Final = Uint64(1) """The number of attestation committees per slot.""" -MAX_ATTESTATIONS_DATA: Final = Uint8(16) +MAX_ATTESTATIONS_DATA: Final = Uint8(15) """Maximum number of distinct attestation data entries per block.""" diff --git a/src/lean_spec/subspecs/networking/transport/quic/connection.py b/src/lean_spec/subspecs/networking/transport/quic/connection.py index a3f50902e..7f53a02de 100644 --- a/src/lean_spec/subspecs/networking/transport/quic/connection.py +++ b/src/lean_spec/subspecs/networking/transport/quic/connection.py @@ -29,9 +29,7 @@ from dataclasses import dataclass, field from pathlib import Path -from aioquic.asyncio import QuicConnectionProtocol -from aioquic.asyncio import connect as quic_connect -from aioquic.asyncio import serve as quic_serve +from aioquic.asyncio import QuicConnectionProtocol, connect as quic_connect, serve as quic_serve from aioquic.quic.configuration import QuicConfiguration from aioquic.quic.events import ( ConnectionTerminated, diff --git a/src/lean_spec/subspecs/sync/service.py b/src/lean_spec/subspecs/sync/service.py index d85a5cc54..13da0711c 100644 --- a/src/lean_spec/subspecs/sync/service.py +++ b/src/lean_spec/subspecs/sync/service.py @@ -533,6 +533,7 @@ async def on_gossip_block( ) self.store = new_store self._replay_pending_attestations() + await self._maybe_publish_reaggregated_attestations_from_block(block) # Each processed block might complete our sync. # @@ -712,6 +713,78 @@ async def publish_aggregated_attestation( """ await self._publish_agg_fn(signed_attestation) + async def _maybe_publish_reaggregated_attestations_from_block( + self, + block: SignedBlock, + ) -> None: + """When running as aggregator, merge block attestations with local partial aggregates. + + On block import we already trust the block-attestation participant bitfields + via `spec.on_block` signature verification. If we also have local partial + aggregates for the same AttestationData, run one aggregation pass immediately + and gossip any improved aggregate for that data. + """ + is_aggregator_role = self.store.validator_id is not None and self.is_aggregator + if not is_aggregator_role: + return + + block_attestations = list(block.block.body.attestations) + if not block_attestations: + return + + # Some tests inject a lightweight mock store that does not carry + # aggregation payload maps. + if not hasattr(self.store, "latest_new_aggregated_payloads"): + return + + # Match attestation entries by data root instead of object identity. + # Different code paths may produce equivalent AttestationData instances + # that do not share the same object key in dicts. + block_participants_by_root = { + hash_tree_root(att.data): set(att.aggregation_bits.to_validator_indices()) + for att in block_attestations + } + pre_new_by_root = { + data_root: [set(proof.info.participants.to_validator_indices()) for proof in proofs] + for data, proofs in self.store.latest_new_aggregated_payloads.items() + if (data_root := hash_tree_root(data)) in block_participants_by_root + } + + # Only run if we already have at least one local partial aggregate for + # some attestation in this block. + if not any(pre_new_by_root.values()): + return + + try: + self.store, new_aggregates = self.spec.aggregate(self.store) + except (AssertionError, KeyError, ValueError) as exc: + logger.debug("Post-block re-aggregation failed: %s", exc) + return + + for signed_attestation in new_aggregates: + data_root = hash_tree_root(signed_attestation.data) + if data_root not in block_participants_by_root: + continue + + local_partials = pre_new_by_root.get(data_root, []) + if not local_partials: + continue + + aggregate_participants = set( + signed_attestation.proof.info.participants.to_validator_indices() + ) + block_participants = block_participants_by_root[data_root] + + # Publish only if this newly produced aggregate strictly improves at + # least one local partial and bridges participants observed in block. + should_publish = any( + aggregate_participants > partial + and bool((block_participants - partial) & aggregate_participants) + for partial in local_partials + ) + if should_publish: + await self.publish_aggregated_attestation(signed_attestation) + async def _check_sync_trigger(self) -> None: """ Check if sync should be triggered based on current state. diff --git a/src/lean_spec/subspecs/validator/service.py b/src/lean_spec/subspecs/validator/service.py index b129b87ce..80dff7ed1 100644 --- a/src/lean_spec/subspecs/validator/service.py +++ b/src/lean_spec/subspecs/validator/service.py @@ -39,9 +39,7 @@ from lean_spec.forks import ( AttestationData, - AttestationSignatures, Block, - BlockSignatures, LstarSpec, SignedAttestation, SignedBlock, @@ -50,9 +48,9 @@ from lean_spec.subspecs.ssz.hash import hash_tree_root from lean_spec.subspecs.sync import SyncService from lean_spec.subspecs.xmss import TARGET_SIGNATURE_SCHEME -from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof +from lean_spec.subspecs.xmss.aggregation import TypeOneMultiSignature, TypeTwoMultiSignature from lean_spec.subspecs.xmss.containers import Signature -from lean_spec.types import Bytes32, Slot, Uint64, ValidatorIndex +from lean_spec.types import ByteListMiB, Bytes32, Slot, Uint64, ValidatorIndex, ValidatorIndices from .constants import HYSTERESIS_BAND, NETWORK_STALL_THRESHOLD, SYNC_LAG_THRESHOLD from .registry import ValidatorEntry, ValidatorRegistry @@ -317,7 +315,8 @@ async def _maybe_produce_block(self, slot: Slot) -> None: self.sync_service.store = new_store - # Sign the block: proposer_signature covers the block root. + # Sign the block: proposer_signature covers the block root, + # and is merged with attestation proofs into one block proof. signed_block = self._sign_block(block, validator_index, signatures) self._blocks_produced += 1 @@ -411,17 +410,21 @@ def _sign_block( self, block: Block, validator_index: ValidatorIndex, - attestation_signatures: list[AggregatedSignatureProof], + attestation_proofs: list[TypeOneMultiSignature], ) -> SignedBlock: """ Sign a block and wrap it for publishing. - Signs hash_tree_root(block) with the proposer's proposal key. + Signs the block root with the proposer's proposal key, wraps the + signature into a singleton Type-1 proof, and merges that with the + per-attestation Type-1 proofs into a single Type-2 proof. The + merged proof is SSZ-encoded and stored on SignedBlock.proof. Args: block: The block to sign. validator_index: Index of the proposing validator. - attestation_signatures: Aggregated signatures for included attestations. + attestation_proofs: Per-AttestationData Type-1 proofs included in + the block body, parallel to block.body.attestations. Returns: Signed block ready for publishing. @@ -439,14 +442,51 @@ def _sign_block( "proposal_secret_key", ) - signature = BlockSignatures( - attestation_signatures=AttestationSignatures(data=attestation_signatures), - proposer_signature=proposer_signature, + # Resolve validator pubkeys from state using validator indices. + key_state = self.sync_service.store.states.get(block_root) + if key_state is None: + key_state = self.sync_service.store.states.get(self.sync_service.store.head) + if key_state is None: + raise ValueError( + "No state available to resolve validator public keys for block signing" + ) + + validators = key_state.validators + if not validator_index.is_valid(Uint64(len(validators))): + raise ValueError(f"Validator {validator_index} not found in state validators") + proposer_pubkey = validators[validator_index].get_proposal_pubkey() + + # Wrap the proposer's raw XMSS signature into a singleton Type-1. + # The participant set is just the proposer index. + proposer_participants = ValidatorIndices(data=[validator_index]).to_aggregation_bits() + proposer_type_1 = TypeOneMultiSignature.aggregate( + children=[], + raw_xmss=[(proposer_pubkey, proposer_signature)], + xmss_participants=proposer_participants, + message=block_root, + slot=block.slot, + ) + + # Merge the per-attestation proofs and the proposer Type-1 into one + # Type-2 proof. Order matters: verify_signatures expects the proposer + # entry to be last, parallel to block.body.attestations + 1. + public_keys_per_part = [ + [ + validators[vid].get_attestation_pubkey() + for vid in proof.info.participants.to_validator_indices() + ] + for proof in attestation_proofs + ] + public_keys_per_part.append([proposer_pubkey]) + + merged = TypeTwoMultiSignature.aggregate( + [*attestation_proofs, proposer_type_1], + public_keys_per_part=public_keys_per_part, ) return SignedBlock( block=block, - signature=signature, + proof=ByteListMiB(data=merged.encode_bytes()), ) def _sign_attestation( diff --git a/src/lean_spec/subspecs/xmss/aggregation.py b/src/lean_spec/subspecs/xmss/aggregation.py index 28863ca55..1d99d59a5 100644 --- a/src/lean_spec/subspecs/xmss/aggregation.py +++ b/src/lean_spec/subspecs/xmss/aggregation.py @@ -1,24 +1,41 @@ -"""Signature aggregation for the Lean Ethereum consensus specification.""" +""" +Signature aggregation for the Lean Ethereum consensus specification. +Multi-signature aggregation containers and helpers. + +Two proof shapes: + +- Type-1: many validators, one message (one AttestationData, or one block root). +- Type-2: a merge of N Type-1 proofs, each over a distinct message. +""" from __future__ import annotations from collections.abc import Sequence -from typing import Self from lean_multisig_py import ( - aggregate_signatures, + aggregate_type_1, + merge_many_type_1, setup_prover, - setup_verifier, - verify_aggregated_signatures, + split_type_2_by_msg, + type1_compress_with_pubkeys, + type1_compress_without_pubkeys, + type1_decompress_with_pubkeys, + type2_compress_with_pubkeys, + type2_compress_without_pubkeys, + type2_decompress_with_pubkeys, + verify_type_1, + verify_type_2, ) from lean_spec.config import LEAN_ENV, LeanEnvMode +from lean_spec.subspecs.chain.config import MAX_ATTESTATIONS_DATA from lean_spec.types import ( AggregationBits, ByteListMiB, Bytes32, Container, Slot, + SSZList, ValidatorIndex, ValidatorIndices, ) @@ -40,55 +57,107 @@ class AggregationError(Exception): - """Raised when signature aggregation or verification fails.""" + """Raised when signature aggregation, merging, splitting, or verification fails.""" + +class TypeOneInfo(Container): + """Per-component metadata for a single-message multi-signer proof. -class AggregatedSignatureProof(Container): + Carries only the participant bitfield. The signed message and slot are + rederived by the verifier from the block body it already trusts, so + they live outside the proof envelope. """ - Cryptographic proof that a set of validators signed a message. - This container encapsulates the output of the leanVM signature aggregation, - combining the participant set with the proof bytes. This design ensures - the proof is self-describing: it carries information about which validators - it covers. + participants: AggregationBits + """Bitfield indicating which validators contributed signatures.""" + + proof: ByteListMiB + """Compact no-pubkeys serialized Type-1 proof bytes.""" + - The proof can verify that all participants signed the same message in the - same slot, using a single verification operation instead of checking - each signature individually. +class TypeOneInfos(SSZList[TypeOneInfo]): + """List of per-message info entries inside a Type-2 proof. + + A valid block carries at most MAX_ATTESTATIONS_DATA total entries + 1, + one is reserved for the proposer's own signature. """ - participants: AggregationBits - """Bitfield indicating which validators' signatures are included.""" + LIMIT = int(MAX_ATTESTATIONS_DATA) + 1 + + +class TypeOneMultiSignature(Container): + """A single-message proof aggregating signatures from many validators.""" + + info: TypeOneInfo + """Participant bitfield for this proof.""" + + proof: ByteListMiB + """Aggregated proof bytes in compact no-pubkeys representation.""" + + def with_public_keys( + self, + public_keys: Sequence[PublicKey], + ) -> tuple[TypeOneMultiSignature, list[PublicKey]]: + """Bind this proof with its participant-ordered public keys for parent merges.""" + keys = list(public_keys) + expected = sum(1 for bit in self.info.participants.data if bool(bit)) + if len(keys) != expected: + raise AggregationError( + f"Type-1 child expected {expected} pubkeys for participants, got {len(keys)}" + ) + return self, keys - proof_data: ByteListMiB - """The raw aggregated proof bytes from leanVM.""" + @staticmethod + def select_greedily( + *proof_sets: set[TypeOneMultiSignature] | None, + ) -> tuple[list[TypeOneMultiSignature], set[ValidatorIndex]]: + """Greedy set-cover over Type-1 proofs to maximise validator coverage. + + Repeatedly selects the proof covering the most uncovered validators + until no proof adds new coverage. Earlier proof sets are + prioritised: gossip-fresh proofs win over already-known ones. + + TODO: a more principled home for this once the proof pool layer + firms up. + """ + selected: list[TypeOneMultiSignature] = [] + covered: set[ValidatorIndex] = set() + + for proofs in proof_sets: + if not proofs: + continue + + remaining = list(proofs) + + while remaining: + best = max( + remaining, + key=lambda p: len(set(p.info.participants.to_validator_indices()) - covered), + ) + new_coverage = set(best.info.participants.to_validator_indices()) - covered - @classmethod + if not new_coverage: + break + + selected.append(best) + covered |= new_coverage + remaining.remove(best) + + return selected, covered + + @staticmethod def aggregate( - cls, - xmss_participants: AggregationBits | None, - children: Sequence[tuple[Self, Sequence[PublicKey]]], + children: Sequence[tuple[TypeOneMultiSignature, Sequence[PublicKey]]], raw_xmss: Sequence[tuple[PublicKey, Signature]], + xmss_participants: AggregationBits | None, message: Bytes32, slot: Slot, mode: LeanEnvMode | None = None, - ) -> Self: - """ - Aggregate raw_xmss signatures and children proofs into a single proof. - - Args: - xmss_participants: Bitfield of validators whose raw_signatures are provided. - children: Sequence of (child_proof, public_keys) tuples to aggregate. - raw_xmss: Sequence of (public key, signature) tuples to aggregate. - message: The 32-byte message that was signed. - slot: The slot in which the signatures were created. - mode: The mode to use for the aggregation (test or prod). + ) -> TypeOneMultiSignature: + """Aggregate raw XMSS signatures and child Type-1 proofs into one Type-1 proof. - Returns: - An aggregated signature proof covering raw signers and all child participants. - - Raises: - AggregationError: If aggregation fails. + Proof bytes are stored in compact no-pubkeys form. Participant identity is + tracked separately in info.participants (attestation bits on the wire). """ if not raw_xmss and not children: raise AggregationError("At least one raw signature or child proof is required") @@ -109,122 +178,303 @@ def aggregate( raise AggregationError("Raw signature count does not match XMSS participant count") # Include child participants in the aggregated participants - for child_proof, _ in children: - aggregated_validator_ids.update(child_proof.participants.to_validator_indices()) - participants = ValidatorIndices(data=list(aggregated_validator_ids)).to_aggregation_bits() + for child, _ in children: + aggregated_validator_ids.update(child.info.participants.to_validator_indices()) + participants = ValidatorIndices(data=sorted(aggregated_validator_ids)).to_aggregation_bits() mode = mode or LEAN_ENV setup_prover(mode=mode) + log_inv_rate = LOG_INV_RATE_TEST if mode == "test" else LOG_INV_RATE_PROD + + raw_pubkeys_ssz = [pk.encode_bytes() for pk, _ in raw_xmss] + raw_signatures_ssz = [sig.encode_bytes() for _, sig in raw_xmss] + + children_bytes: list[tuple[list[bytes], bytes]] = [] + for idx, (child, child_public_keys_raw) in enumerate(children): + child_public_keys = list(child_public_keys_raw) + expected = sum(1 for bit in child.info.participants.data if bool(bit)) + if len(child_public_keys) != expected: + raise AggregationError( + f"Type-1 aggregate child {idx} expected {expected} pubkeys, " + f"got {len(child_public_keys)}" + ) + + child_pks_ssz = [pk.encode_bytes() for pk in child_public_keys] + child_wire = bytes(child.proof.data) + if not child_wire: + raise AggregationError(f"Child proof {idx} has empty proof bytes") + children_bytes.append((child_pks_ssz, child_wire)) try: - children_bytes = [ - ( - [pk.encode_bytes() for pk in child_pks], - child_proof.proof_data.encode_bytes(), - ) - for child_proof, child_pks in children - ] - _, proof_bytes = aggregate_signatures( - [pk.encode_bytes() for pk, _ in raw_xmss], - [sig.encode_bytes() for _, sig in raw_xmss], - message, - slot, - LOG_INV_RATE_TEST if mode == "test" else LOG_INV_RATE_PROD, - children_bytes=children_bytes, + sorted_pks_ssz, type1_wire = aggregate_type_1( + raw_pubkeys_ssz, + raw_signatures_ssz, + bytes(message), + int(slot), + log_inv_rate, + children_bytes if children_bytes else None, mode=mode, ) - return cls( + except Exception as exc: + raise AggregationError(f"Type-1 aggregation failed: {exc}") from exc + + # Canonicalise to the compact no-pubkeys form the verifier expects. + type1_wire = _coerce_type1_wire(type1_wire, sorted_pks_ssz, mode) + + return TypeOneMultiSignature( + info=TypeOneInfo( participants=participants, - proof_data=ByteListMiB(data=proof_bytes), + proof=ByteListMiB(data=type1_wire), + ), + proof=ByteListMiB(data=type1_wire), + ) + + def verify( + self, + public_keys: Sequence[PublicKey], + message: Bytes32, + slot: Slot, + mode: LeanEnvMode | None = None, + ) -> None: + """Verify this single-message Type-1 proof against a resolved set of pubkeys. + + The pubkey list must be parallel to info.participants (one entry per + set bit, in the same order). The message and slot are supplied by + the caller — they are not stored on the proof. Raises + AggregationError on any binding rejection or structural mismatch. + """ + mode = mode or LEAN_ENV + setup_prover(mode=mode) + + expected = sum(1 for bit in self.info.participants.data if bool(bit)) + if len(public_keys) != expected: + raise AggregationError( + f"Type-1 verify expected {expected} pubkeys for participants, " + f"got {len(public_keys)}" + ) + + pks_ssz = [pk.encode_bytes() for pk in public_keys] + proof_wire = _coerce_type1_wire(bytes(self.proof.data), pks_ssz, mode) + try: + verify_type_1( + pks_ssz, + bytes(message), + int(slot), + bytes(proof_wire), + mode=mode, ) except Exception as exc: - raise AggregationError(f"Signature aggregation failed: {exc}") from exc + raise AggregationError(f"Type-1 verification failed: {exc}") from exc + + +class TypeTwoMultiSignature(Container): + """A merged proof covering many distinct messages. + + On the wire a SignedBlock carries the SSZ-serialised form of this + container as its single proof blob. The block-level info list + enumerates the participant bitfield for every merged Type-1 + component. Messages and slots are rederived by the verifier from + the block body, not duplicated in the proof. + """ + + info: TypeOneInfos + """Per-message metadata, one entry per merged Type-1 proof.""" + + proof: ByteListMiB + """Compact no-pubkeys serialized Type-2 proof bytes.""" @staticmethod - def select_greedily( - *proof_sets: set[AggregatedSignatureProof] | None, - ) -> tuple[list[AggregatedSignatureProof], set[ValidatorIndex]]: + def aggregate( + parts: Sequence[TypeOneMultiSignature], + public_keys_per_part: Sequence[Sequence[PublicKey]] | None = None, + mode: LeanEnvMode | None = None, + ) -> TypeTwoMultiSignature: + """Merge several Type-1 proofs (each over a distinct message) into one Type-2 proof. + + The returned Type-2 proof bytes are stored in compact no-pubkeys form. """ - Greedy set-cover selection of proofs to maximize validator coverage. + if not parts: + raise AggregationError("Type-2 aggregate requires at least one Type-1 input") - Repeatedly selects the proof covering the most uncovered validators - until no proof adds new coverage. Earlier proof sets are prioritized. + mode = mode or LEAN_ENV + setup_prover(mode=mode) + log_inv_rate = LOG_INV_RATE_TEST if mode == "test" else LOG_INV_RATE_PROD + + if public_keys_per_part is not None and len(public_keys_per_part) != len(parts): + raise AggregationError( + f"Type-2 aggregate expected pubkeys for {len(parts)} parts, " + f"got {len(public_keys_per_part)}" + ) + + type1_entries: list[tuple[list[bytes], bytes]] = [] + for idx, part in enumerate(parts): + expected = sum(1 for bit in part.info.participants.data if bool(bit)) + if public_keys_per_part is None: + raise AggregationError( + "public_keys_per_part is required when Type-1 proofs are stored without pubkeys" + ) + pubkeys = list(public_keys_per_part[idx]) + if len(pubkeys) != expected: + raise AggregationError( + f"Type-2 aggregate entry {idx} expected {expected} pubkeys, got {len(pubkeys)}" + ) + pks_ssz = [pk.encode_bytes() for pk in pubkeys] + type1_entries.append((pks_ssz, bytes(part.proof.data))) + + try: + pks_per_component_ssz, type2_wire = merge_many_type_1( + type1_entries, log_inv_rate, mode=mode + ) + except Exception as exc: + raise AggregationError(f"Type-2 aggregation failed: {exc}") from exc + + # Canonicalise to the compact no-pubkeys form the verifier expects. + type2_wire = _coerce_type2_wire(type2_wire, pks_per_component_ssz, mode) - TODO: We should find a better place for this in the future. + return TypeTwoMultiSignature( + info=TypeOneInfos(data=[part.info for part in parts]), + proof=ByteListMiB(data=type2_wire), + ) - Args: - proof_sets: Candidate proof sets in priority order. + def split_by_msg( + self, + entry_index: int, + message: Bytes32, + public_keys_per_message: Sequence[Sequence[PublicKey]], + mode: LeanEnvMode | None = None, + ) -> TypeOneMultiSignature: + """Recover the Type-1 proof bound to a specific message from this Type-2 merge. - Returns: - Selected proofs and the set of covered validator indices. + The caller is responsible for knowing which entry index of self.info + corresponds to the message being split out — the proof envelope no + longer stores per-entry messages. """ - selected: list[AggregatedSignatureProof] = [] - covered: set[ValidatorIndex] = set() + mode = mode or LEAN_ENV + setup_prover(mode=mode) + log_inv_rate = LOG_INV_RATE_TEST if mode == "test" else LOG_INV_RATE_PROD - # Process each priority tier in order. - # - # Earlier sets are exhausted before moving to later ones. - # This ensures new (pending) proofs are preferred over known - # (already-accepted) proofs, reducing redundant work. - for proofs in proof_sets: - if not proofs: - continue + if not 0 <= entry_index < len(self.info): + raise AggregationError( + f"Type-2 split entry_index {entry_index} out of range for {len(self.info)} entries" + ) - remaining = list(proofs) + entry = self.info[entry_index] - # Greedy set-cover: repeatedly pick the proof that adds the - # most uncovered validators. - # - # The greedy approach guarantees a logarithmic approximation - # ratio, which is good enough for block building where we want - # maximum coverage with minimal proof count. - while remaining: - best = max( - remaining, - key=lambda p: len(set(p.participants.to_validator_indices()) - covered), + if len(public_keys_per_message) != len(self.info): + raise AggregationError( + f"Type-2 split expected pubkey lists for {len(self.info)} messages, " + f"got {len(public_keys_per_message)}" + ) + + pub_keys_per_component_ssz: list[list[bytes]] = [] + for idx, (info, pks) in enumerate(zip(self.info, public_keys_per_message, strict=True)): + expected = sum(1 for bit in info.participants.data if bool(bit)) + if len(pks) != expected: + raise AggregationError( + f"Type-2 split entry {idx} expected {expected} pubkeys, got {len(pks)}" ) - new_coverage = set(best.participants.to_validator_indices()) - covered + pub_keys_per_component_ssz.append([pk.encode_bytes() for pk in pks]) - # No proof in this tier adds new coverage. - # Remaining proofs are fully redundant with what we already have. - if not new_coverage: - break + type2_wire = _coerce_type2_wire(bytes(self.proof.data), pub_keys_per_component_ssz, mode) + try: + pks_ssz, type1_wire = split_type_2_by_msg( + pub_keys_per_component_ssz, + bytes(type2_wire), + bytes(message), + log_inv_rate, + mode=mode, + ) + except Exception as exc: + raise AggregationError(f"Type-2 split-by-message failed: {exc}") from exc - selected.append(best) - covered |= new_coverage - remaining.remove(best) + type1_wire = _coerce_type1_wire(type1_wire, pks_ssz, mode) - return selected, covered + return TypeOneMultiSignature( + info=entry, + proof=ByteListMiB(data=type1_wire), + ) def verify( self, - public_keys: Sequence[PublicKey], - message: Bytes32, - slot: Slot, + public_keys_per_message: Sequence[Sequence[PublicKey]], mode: LeanEnvMode | None = None, ) -> None: - """ - Verify this aggregated signature proof. + """Verify this multi-message Type-2 proof against per-entry resolved pubkeys. - Args: - public_keys: Public keys of the participants. - message: The 32-byte message that was signed. - slot: The slot in which the signatures were created. - mode: The mode to use for the verification (test or prod). - - Raises: - AggregationError: If verification fails. + public_keys_per_message must be parallel to self.info: one pubkey + list per Type-1 info entry, ordered by that entry's participants + bitfield. """ mode = mode or LEAN_ENV - setup_verifier(mode=mode) + setup_prover(mode=mode) - try: - verify_aggregated_signatures( - [pk.encode_bytes() for pk in public_keys], - message, - self.proof_data.encode_bytes(), - slot, - mode=mode, + if len(public_keys_per_message) != len(self.info): + raise AggregationError( + f"Type-2 verify expected pubkey lists for {len(self.info)} messages, " + f"got {len(public_keys_per_message)}" ) + + pub_keys_per_component_ssz: list[list[bytes]] = [] + for idx, (info, pks) in enumerate(zip(self.info, public_keys_per_message, strict=True)): + expected = sum(1 for bit in info.participants.data if bool(bit)) + if len(pks) != expected: + raise AggregationError( + f"Type-2 verify entry {idx} expected {expected} pubkeys, got {len(pks)}" + ) + pub_keys_per_component_ssz.append([pk.encode_bytes() for pk in pks]) + + type2_wire = _coerce_type2_wire(bytes(self.proof.data), pub_keys_per_component_ssz, mode) + try: + verify_type_2(pub_keys_per_component_ssz, type2_wire, mode=mode) except Exception as exc: - raise AggregationError(f"Signature verification failed: {exc}") from exc + raise AggregationError(f"Type-2 verification failed: {exc}") from exc + + +def _coerce_type1_wire(sig_bytes: bytes, pub_keys_ssz: list[bytes], mode: LeanEnvMode) -> bytes: + """Normalise Type-1 bytes to the compact no-pubkeys form. + + The lean_multisig_py binding emits proofs in different layouts depending + on which entry point produced them (aggregate, split, merge). This helper + funnels every shape into the canonical no-pubkeys form expected by + verify_type_1 and re-aggregation flows. + """ + try: + return type1_compress_without_pubkeys(sig_bytes, mode=mode) + except Exception: + pass + + try: + bundled = type1_compress_with_pubkeys(pub_keys_ssz, sig_bytes, mode=mode) + return type1_compress_without_pubkeys(bundled, mode=mode) + except Exception: + pass + + try: + _, no_pubkeys = type1_decompress_with_pubkeys(sig_bytes, mode=mode) + return no_pubkeys + except Exception: + return sig_bytes + + +def _coerce_type2_wire( + sig_bytes: bytes, + pub_keys_per_component: list[list[bytes]], + mode: LeanEnvMode, +) -> bytes: + """Normalise Type-2 bytes to the compact no-pubkeys form.""" + try: + return type2_compress_without_pubkeys(sig_bytes, mode=mode) + except Exception: + pass + + try: + bundled = type2_compress_with_pubkeys(pub_keys_per_component, sig_bytes, mode=mode) + return type2_compress_without_pubkeys(bundled, mode=mode) + except Exception: + pass + + try: + _, no_pubkeys = type2_decompress_with_pubkeys(sig_bytes, mode=mode) + return no_pubkeys + except Exception: + return sig_bytes diff --git a/src/lean_spec/types/byte_arrays.py b/src/lean_spec/types/byte_arrays.py index bdd1e3617..5eb7e9eb5 100644 --- a/src/lean_spec/types/byte_arrays.py +++ b/src/lean_spec/types/byte_arrays.py @@ -389,6 +389,6 @@ def hex(self) -> str: class ByteListMiB(BaseByteList): - """Variable-length byte list with a limit of 1048576 bytes.""" + """Variable-length byte list with an 8 MiB limit.""" - LIMIT = 1024 * 1024 + LIMIT = 8 * 1024 * 1024 diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 61d7c07c5..450ade809 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -9,15 +9,10 @@ import httpx import pytest -from lean_spec.forks import ( - AttestationSignatures, - BlockSignatures, - SignedBlock, -) +from lean_spec.forks import SignedBlock from lean_spec.subspecs.api import AggregatorController, ApiServer, ApiServerConfig from lean_spec.subspecs.ssz.hash import hash_tree_root -from lean_spec.types import Bytes32 -from tests.lean_spec.helpers import make_mock_signature +from lean_spec.types import ByteListMiB, Bytes32 from tests.lean_spec.helpers.builders import make_genesis_data # Default port for auto-started local server @@ -84,10 +79,7 @@ def _create_server(self) -> ApiServer: # at least the finalized root; see ApiServer.signed_block_getter. anchor_signed_block = SignedBlock( block=genesis.block, - signature=BlockSignatures( - attestation_signatures=AttestationSignatures(data=[]), - proposer_signature=make_mock_signature(), - ), + proof=ByteListMiB(data=b""), ) anchor_root = hash_tree_root(genesis.block) diff --git a/tests/consensus/lstar/fc/conftest.py b/tests/consensus/lstar/fc/conftest.py new file mode 100644 index 000000000..6785b29c6 --- /dev/null +++ b/tests/consensus/lstar/fc/conftest.py @@ -0,0 +1,21 @@ +"""Shared fixtures for fork-choice filler tests.""" + +from collections.abc import Iterator + +import pytest +from consensus_testing.keys import XmssKeyManager + + +@pytest.fixture(autouse=True) +def _reset_xmss_signing_state() -> Iterator[None]: + """Reset cached XMSS signing state around every fork-choice filler. + + XMSS keys are stateful and advance past used slots on every sign. + Without a reset, a filler that signs at a high slot poisons the + shared cache for any later filler that needs to sign at a lower + slot — leading to "Verification failed" errors that only appear + when several tests share a worker. + """ + XmssKeyManager.reset_signing_state() + yield + XmssKeyManager.reset_signing_state() diff --git a/tests/consensus/lstar/fc/test_block_attestation_limits.py b/tests/consensus/lstar/fc/test_block_attestation_limits.py index 0903d5121..6b9ae4d93 100644 --- a/tests/consensus/lstar/fc/test_block_attestation_limits.py +++ b/tests/consensus/lstar/fc/test_block_attestation_limits.py @@ -10,7 +10,6 @@ StoreChecks, generate_pre_state, ) -from consensus_testing.keys import XmssKeyManager from lean_spec.subspecs.chain.config import MAX_ATTESTATIONS_DATA from lean_spec.types import Slot, ValidatorIndex @@ -18,19 +17,6 @@ pytestmark = pytest.mark.valid_until("Lstar") -@pytest.fixture(autouse=True) -def _reset_xmss_signing_state(): - """Reset XMSS signing state around each test in this module. - - Tests here sign at high slots (50+). Without resetting, the advanced - key state poisons the shared manager for later tests on the same - worker that need low-slot signatures. - """ - XmssKeyManager.reset_signing_state() - yield - XmssKeyManager.reset_signing_state() - - def _justifiable_slots(n: int) -> list[Slot]: """Return the first N justifiable slots after finalized genesis (slot 0).""" slots: list[Slot] = [] @@ -46,7 +32,7 @@ def test_block_with_maximum_attestations( fork_choice_test: ForkChoiceTestFiller, ) -> None: """ - Block with MAX_ATTESTATIONS_DATA distinct entries is accepted by the store. + Block with MAX_ATTESTATIONS_DATA + 1 (proposer) distinct entries is accepted by the store. Scenario -------- @@ -61,7 +47,7 @@ def test_block_with_maximum_attestations( 1. Store accepts the block without errors 2. Head advances to the final block slot """ - n = int(MAX_ATTESTATIONS_DATA) + n = int(MAX_ATTESTATIONS_DATA) + 1 targets = _justifiable_slots(n) proposal_slot = Slot(targets[-1] + Slot(1)) @@ -107,14 +93,15 @@ def test_block_exceeding_maximum_attestations_is_rejected( fork_choice_test: ForkChoiceTestFiller, ) -> None: """ - Block with MAX_ATTESTATIONS_DATA + 1 distinct entries is rejected by the store. + Block with more than MAX_ATTESTATIONS_DATA distinct entries is rejected. Scenario -------- - 1. Build the same chain as the maximum test, but with one extra justifiable - target slot - 2. The final block carries MAX_ATTESTATIONS_DATA entries through the normal - builder, plus one forced attestation that pushes the count over the limit + 1. Build a chain with one block per justifiable slot, enough to host + MAX_ATTESTATIONS_DATA + 1 distinct targets + 2. The final block carries MAX_ATTESTATIONS_DATA entries through the + builder, plus one forced attestation that pushes the total in the + body to MAX_ATTESTATIONS_DATA + 1 Expected Behavior ----------------- diff --git a/tests/consensus/lstar/fc/test_block_production.py b/tests/consensus/lstar/fc/test_block_production.py index d46e861f0..8fe666c41 100644 --- a/tests/consensus/lstar/fc/test_block_production.py +++ b/tests/consensus/lstar/fc/test_block_production.py @@ -272,12 +272,15 @@ def test_produce_block_enforces_max_attestations_data_limit( ---------------------- 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. + entries with the highest target slots. The proposer signature occupies + the remaining slot in the Type-2 proof envelope. Expected post-state ------------------- The produced block contains exactly MAX_ATTESTATIONS_DATA attestations. """ + # The builder admits up to MAX_ATTESTATIONS_DATA distinct entries; the + # proposer signature occupies the remaining slot in the Type-2 envelope. limit = int(MAX_ATTESTATIONS_DATA) num_target_blocks = limit + 1 block_production_slot = num_target_blocks + 1 diff --git a/tests/consensus/lstar/ssz/test_consensus_containers.py b/tests/consensus/lstar/ssz/test_consensus_containers.py index 333931ddf..0691e629b 100644 --- a/tests/consensus/lstar/ssz/test_consensus_containers.py +++ b/tests/consensus/lstar/ssz/test_consensus_containers.py @@ -18,11 +18,7 @@ SignedBlock, Validator, ) -from lean_spec.forks.lstar.containers.block import BlockSignatures -from lean_spec.forks.lstar.containers.block.types import ( - AggregatedAttestations, - AttestationSignatures, -) +from lean_spec.forks.lstar.containers.block.types import AggregatedAttestations from lean_spec.forks.lstar.containers.state.types import ( HistoricalBlockHashes, JustificationRoots, @@ -30,7 +26,7 @@ JustifiedSlots, ) from lean_spec.forks.lstar.containers.validator import Validators -from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof +from lean_spec.subspecs.xmss.aggregation import TypeOneInfo, TypeOneMultiSignature from lean_spec.types import ( AggregationBits, Boolean, @@ -257,43 +253,11 @@ def test_block_typical(ssz: SSZTestFiller) -> None: ) -# --- BlockSignatures --- - - -def test_block_signatures_empty(ssz: SSZTestFiller) -> None: - """SSZ roundtrip for BlockSignatures with no attestation signatures.""" - ssz( - type_name="BlockSignatures", - value=BlockSignatures( - attestation_signatures=AttestationSignatures(data=[]), - proposer_signature=create_dummy_signature(), - ), - ) - - -def test_block_signatures_with_attestation(ssz: SSZTestFiller) -> None: - """SSZ roundtrip for BlockSignatures with attestation signatures.""" - ssz( - type_name="BlockSignatures", - value=BlockSignatures( - attestation_signatures=AttestationSignatures( - data=[ - AggregatedSignatureProof( - participants=AggregationBits(data=[Boolean(True)]), - proof_data=ByteListMiB(data=b""), - ) - ] - ), - proposer_signature=create_dummy_signature(), - ), - ) - - # --- SignedBlock --- def test_signed_block_minimal(ssz: SSZTestFiller) -> None: - """SSZ roundtrip for SignedBlock with minimal values.""" + """SSZ roundtrip for SignedBlock with empty proof bytes.""" block = Block( slot=Slot(1), proposer_index=ValidatorIndex(0), @@ -301,13 +265,24 @@ def test_signed_block_minimal(ssz: SSZTestFiller) -> None: state_root=Bytes32.zero(), body=BlockBody(attestations=AggregatedAttestations(data=[])), ) - signature = BlockSignatures( - attestation_signatures=AttestationSignatures(data=[]), - proposer_signature=create_dummy_signature(), + ssz( + type_name="SignedBlock", + value=SignedBlock(block=block, proof=ByteListMiB(data=b"")), + ) + + +def test_signed_block_with_proof_bytes(ssz: SSZTestFiller) -> None: + """SSZ roundtrip for SignedBlock with non-empty proof bytes.""" + block = Block( + slot=Slot(2), + proposer_index=ValidatorIndex(1), + parent_root=Bytes32(b"\x01" * 32), + state_root=Bytes32(b"\x02" * 32), + body=BlockBody(attestations=AggregatedAttestations(data=[])), ) ssz( type_name="SignedBlock", - value=SignedBlock(block=block, signature=signature), + value=SignedBlock(block=block, proof=ByteListMiB(data=b"\xde\xad\xbe\xef")), ) @@ -446,13 +421,17 @@ def test_state_with_validators(ssz: SSZTestFiller) -> None: def test_signed_aggregated_attestation_minimal(ssz: SSZTestFiller) -> None: """SSZ roundtrip for SignedAggregatedAttestation with one participant and empty proof.""" + data = _zero_attestation_data() ssz( type_name="SignedAggregatedAttestation", value=SignedAggregatedAttestation( - data=_zero_attestation_data(), - proof=AggregatedSignatureProof( - participants=AggregationBits(data=[Boolean(True)]), - proof_data=ByteListMiB(data=b""), + data=data, + proof=TypeOneMultiSignature( + info=TypeOneInfo( + participants=AggregationBits(data=[Boolean(True)]), + proof=ByteListMiB(data=b""), + ), + proof=ByteListMiB(data=b""), ), ), ) @@ -460,15 +439,20 @@ def test_signed_aggregated_attestation_minimal(ssz: SSZTestFiller) -> None: def test_signed_aggregated_attestation_typical(ssz: SSZTestFiller) -> None: """SSZ roundtrip for SignedAggregatedAttestation with mixed participation bits.""" + data = _typical_attestation_data() + wire = b"\xca\xfe\xba\xbe\xde\xad" ssz( type_name="SignedAggregatedAttestation", value=SignedAggregatedAttestation( - data=_typical_attestation_data(), - proof=AggregatedSignatureProof( - participants=AggregationBits( - data=[Boolean(True), Boolean(False), Boolean(True), Boolean(True)] + data=data, + proof=TypeOneMultiSignature( + info=TypeOneInfo( + participants=AggregationBits( + data=[Boolean(True), Boolean(False), Boolean(True), Boolean(True)] + ), + proof=ByteListMiB(data=wire), ), - proof_data=ByteListMiB(data=b"\xca\xfe\xba\xbe\xde\xad"), + proof=ByteListMiB(data=wire), ), ), ) diff --git a/tests/consensus/lstar/ssz/test_multisig_bindings_integration.py b/tests/consensus/lstar/ssz/test_multisig_bindings_integration.py new file mode 100644 index 000000000..bf56458e4 --- /dev/null +++ b/tests/consensus/lstar/ssz/test_multisig_bindings_integration.py @@ -0,0 +1,304 @@ +"""Integration tests for devnet5 multisig bindings used by leanSpec. + +These tests exercise the real `lean_multisig_py` API for: + +- Type-1 aggregation + verification +- Type-2 merge + verification +- Deconstruction via split/decompress helpers + +The raw XMSS vectors are deterministic and stored in +`tests/data/multisig/devnet5_vectors.json`. +""" + +from __future__ import annotations + +import importlib +import json +import os +import sys +from pathlib import Path +from typing import Any, SupportsInt + +import pytest + +from lean_spec.config import LeanEnvMode +from lean_spec.subspecs.xmss.aggregation import ( + AggregationError, + TypeOneInfo, + TypeOneInfos, + TypeOneMultiSignature, + TypeTwoMultiSignature, +) +from lean_spec.subspecs.xmss.containers import PublicKey +from lean_spec.types import ByteListMiB, Bytes32, Slot, ValidatorIndex, ValidatorIndices + +VECTORS_PATH = Path(__file__).resolve().parents[3] / "data/multisig/devnet5_vectors.json" +REQUIRED_APIS = ( + "setup_prover", + "setup_verifier", + "aggregate_type_1", + "verify_type_1", + "merge_many_type_1", + "verify_type_2", + "split_type_2", + "split_type_2_by_msg", + "type1_compress_with_pubkeys", + "type1_decompress_with_pubkeys", + "type1_compress_without_pubkeys", + "type2_compress_with_pubkeys", + "type2_decompress_with_pubkeys", + "type2_compress_without_pubkeys", + "ssz_encode_type1_signature", + "ssz_decode_type1_signature", + "ssz_encode_type2_signature", + "ssz_decode_type2_signature", +) + + +def _load_bindings_module(): + workspace_override = os.environ.get("LEAN_MULTISIG_BINDINGS_PATH") + if workspace_override: + workspace = Path(workspace_override) + if not workspace.exists(): + raise RuntimeError(f"LEAN_MULTISIG_BINDINGS_PATH does not exist: {workspace}") + sys.path.insert(0, str(workspace)) + + # Force reload so this test can prefer the explicit workspace override + # over any previously imported site-package variant in the same process. + for name in list(sys.modules): + if name == "lean_multisig_py" or name.startswith("lean_multisig_py."): + sys.modules.pop(name, None) + importlib.invalidate_caches() + + return importlib.import_module("lean_multisig_py") + + +@pytest.fixture(scope="session") +def bindings_and_mode() -> tuple[Any, LeanEnvMode]: + try: + lm = _load_bindings_module() + except Exception as exc: # pragma: no cover - environment-specific + pytest.skip(f"lean_multisig_py import failed: {exc}") + + missing = [name for name in REQUIRED_APIS if not hasattr(lm, name)] + if missing: + pytest.skip("lean_multisig_py does not expose devnet5 APIs: " + ", ".join(sorted(missing))) + + if getattr(lm, "_module", None) is not None: + mode = "prod" + elif getattr(lm, "_test_module", None) is not None: + mode = "test" + else: + pytest.skip("No usable lean_multisig_py runtime module loaded") + + lm.setup_prover(mode=mode) + lm.setup_verifier(mode=mode) + return lm, mode + + +@pytest.fixture(scope="session") +def vectors() -> dict[str, bytes | int]: + raw = json.loads(VECTORS_PATH.read_text()) + return { + "msg_a": bytes.fromhex(raw["msg_a"]), + "msg_b": bytes.fromhex(raw["msg_b"]), + "slot_a": int(raw["slot_a"]), + "slot_b": int(raw["slot_b"]), + "pk_a": bytes.fromhex(raw["pk_a"]), + "pk_b": bytes.fromhex(raw["pk_b"]), + "sig_a": bytes.fromhex(raw["sig_a"]), + "sig_b": bytes.fromhex(raw["sig_b"]), + } + + +def test_type_1_aggregation_deconstruction_and_verification( + bindings_and_mode: tuple[Any, LeanEnvMode], + vectors: dict[str, Any], +) -> None: + lm, mode = bindings_and_mode + + pks_a, type1_a = lm.aggregate_type_1( + [vectors["pk_a"]], + [vectors["sig_a"]], + vectors["msg_a"], + vectors["slot_a"], + 1, + mode=mode, + ) + lm.verify_type_1(pks_a, vectors["msg_a"], vectors["slot_a"], type1_a, mode=mode) + + compact = lm.type1_compress_with_pubkeys(pks_a, type1_a, mode=mode) + compact_no_pubkeys = lm.type1_compress_without_pubkeys(compact, mode=mode) + assert compact_no_pubkeys == type1_a + compact_rehydrated = lm.type1_compress_with_pubkeys(pks_a, compact_no_pubkeys, mode=mode) + assert compact_rehydrated == compact + pks_roundtrip, type1_roundtrip = lm.type1_decompress_with_pubkeys(compact, mode=mode) + lm.verify_type_1( + pks_roundtrip, + vectors["msg_a"], + vectors["slot_a"], + type1_roundtrip, + mode=mode, + ) + + # Roundtrip the no-pubkeys Type-1 blob through SSZ wrappers. + type1_ssz = lm.ssz_encode_type1_signature(type1_roundtrip, mode=mode) + type1_no_pubkeys = lm.ssz_decode_type1_signature(type1_ssz, mode=mode) + lm.verify_type_1( + pks_roundtrip, + vectors["msg_a"], + vectors["slot_a"], + type1_no_pubkeys, + mode=mode, + ) + + # Build leanSpec Type-1 payload container and verify through wrapper. + type1_payload = TypeOneMultiSignature( + info=TypeOneInfo( + participants=ValidatorIndices(data=[ValidatorIndex(0)]).to_aggregation_bits(), + proof=ByteListMiB(data=type1_no_pubkeys), + ), + proof=ByteListMiB(data=type1_no_pubkeys), + ) + decoded_type1_payload = TypeOneMultiSignature.decode_bytes(type1_payload.encode_bytes()) + decoded_type1_payload.verify( + [PublicKey.decode_bytes(pk_ssz) for pk_ssz in pks_roundtrip], + message=Bytes32(vectors["msg_a"]), + slot=Slot(vectors["slot_a"]), + mode=mode, + ) + + +def test_type_2_merge_split_deconstruction_and_verification( + bindings_and_mode: tuple[Any, LeanEnvMode], + vectors: dict[str, Any], +) -> None: + lm, mode = bindings_and_mode + + type1_a = lm.aggregate_type_1( + [vectors["pk_a"]], + [vectors["sig_a"]], + vectors["msg_a"], + vectors["slot_a"], + 1, + mode=mode, + ) + type1_b = lm.aggregate_type_1( + [vectors["pk_b"]], + [vectors["sig_b"]], + vectors["msg_b"], + vectors["slot_b"], + 1, + mode=mode, + ) + + pks_per_component, type2 = lm.merge_many_type_1([type1_a, type1_b], 1, mode=mode) + lm.verify_type_2(pks_per_component, type2, mode=mode) + + compact = lm.type2_compress_with_pubkeys(pks_per_component, type2, mode=mode) + compact_no_pubkeys = lm.type2_compress_without_pubkeys(compact, mode=mode) + assert compact_no_pubkeys == type2 + compact_rehydrated = lm.type2_compress_with_pubkeys( + pks_per_component, compact_no_pubkeys, mode=mode + ) + assert compact_rehydrated == compact + pks_roundtrip, type2_roundtrip = lm.type2_decompress_with_pubkeys(compact, mode=mode) + lm.verify_type_2(pks_roundtrip, type2_roundtrip, mode=mode) + + # Roundtrip the no-pubkeys Type-2 blob through SSZ wrappers. + type2_ssz = lm.ssz_encode_type2_signature(type2_roundtrip, mode=mode) + type2_no_pubkeys = lm.ssz_decode_type2_signature(type2_ssz, mode=mode) + lm.verify_type_2(pks_roundtrip, type2_no_pubkeys, mode=mode) + + split_by_msg_pks, split_by_msg_type1 = lm.split_type_2_by_msg( + pks_roundtrip, + type2_no_pubkeys, + vectors["msg_a"], + 1, + mode=mode, + ) + lm.verify_type_1( + split_by_msg_pks, + vectors["msg_a"], + vectors["slot_a"], + split_by_msg_type1, + mode=mode, + ) + + split_by_index_pks, split_by_index_type1 = lm.split_type_2( + pks_roundtrip, + type2_no_pubkeys, + 1, + 1, + mode=mode, + ) + lm.verify_type_1( + split_by_index_pks, + vectors["msg_b"], + vectors["slot_b"], + split_by_index_type1, + mode=mode, + ) + + # Build leanSpec Type-2 payload container and verify through wrapper. + type2_payload = TypeTwoMultiSignature( + info=TypeOneInfos( + data=[ + TypeOneInfo( + participants=ValidatorIndices(data=[ValidatorIndex(0)]).to_aggregation_bits(), + proof=ByteListMiB(data=type1_a[1]), + ), + TypeOneInfo( + participants=ValidatorIndices(data=[ValidatorIndex(1)]).to_aggregation_bits(), + proof=ByteListMiB(data=type1_b[1]), + ), + ] + ), + proof=ByteListMiB(data=type2_no_pubkeys), + ) + decoded_type2_payload = TypeTwoMultiSignature.decode_bytes(type2_payload.encode_bytes()) + decoded_type2_payload.verify( + [ + [PublicKey.decode_bytes(pk_ssz) for pk_ssz in pks_roundtrip[0]], + [PublicKey.decode_bytes(pk_ssz) for pk_ssz in pks_roundtrip[1]], + ], + mode=mode, + ) + + +def test_type_1_info_participant_cardinality_validation( + bindings_and_mode: tuple[Any, LeanEnvMode], + vectors: dict[str, SupportsInt], +) -> None: + """Type-1 verification enforces pubkey count equals participant cardinality.""" + lm, mode = bindings_and_mode + pks_ssz, type1_wire = lm.aggregate_type_1( + [vectors["pk_a"]], + [vectors["sig_a"]], + vectors["msg_a"], + vectors["slot_a"], + 1, + mode=mode, + ) + pk = PublicKey.decode_bytes(pks_ssz[0]) + message = Bytes32(vectors["msg_a"]) + slot = Slot(vectors["slot_a"]) + participants = ValidatorIndices(data=[ValidatorIndex(0)]).to_aggregation_bits() + proof = ByteListMiB(data=type1_wire) + + single_participant = TypeOneMultiSignature( + info=TypeOneInfo(participants=participants, proof=proof), + proof=proof, + ) + single_participant.verify([pk], message=message, slot=slot, mode=mode) + + # Mismatched participant cardinality is rejected. + invalid_bits = ValidatorIndices( + data=[ValidatorIndex(0), ValidatorIndex(1)] + ).to_aggregation_bits() + invalid = TypeOneMultiSignature( + info=TypeOneInfo(participants=invalid_bits, proof=proof), + proof=proof, + ) + with pytest.raises(AggregationError, match="expected 2 pubkeys"): + invalid.verify([pk], message=message, slot=slot, mode=mode) diff --git a/tests/consensus/lstar/ssz/test_xmss_containers.py b/tests/consensus/lstar/ssz/test_xmss_containers.py index c7cd583b6..a0a24c99b 100644 --- a/tests/consensus/lstar/ssz/test_xmss_containers.py +++ b/tests/consensus/lstar/ssz/test_xmss_containers.py @@ -6,7 +6,12 @@ from lean_spec.subspecs.koalabear import Fp from lean_spec.subspecs.xmss import PublicKey -from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof +from lean_spec.subspecs.xmss.aggregation import ( + TypeOneInfo, + TypeOneInfos, + TypeOneMultiSignature, + TypeTwoMultiSignature, +) from lean_spec.subspecs.xmss.types import ( HASH_DIGEST_LENGTH, HashDigestList, @@ -69,47 +74,50 @@ def test_signature_actual(ssz: SSZTestFiller) -> None: ssz(type_name="Signature", value=signature) -# --- AggregatedSignatureProof --- +# --- TypeOneInfo / TypeOneMultiSignature / TypeTwoMultiSignature --- + + +def _info(participants: list[bool], wire: bytes) -> TypeOneInfo: + """Build a Type-1 info entry with the wire bytes echoed in info.proof.""" + return TypeOneInfo( + participants=AggregationBits(data=[Boolean(b) for b in participants]), + proof=ByteListMiB(data=wire), + ) -def test_aggregated_signature_proof_empty(ssz: SSZTestFiller) -> None: - """SSZ roundtrip for AggregatedSignatureProof with empty proof data.""" +def test_type_one_multi_signature_empty(ssz: SSZTestFiller) -> None: + """SSZ roundtrip for a Type-1 proof with empty proof bytes.""" ssz( - type_name="AggregatedSignatureProof", - value=AggregatedSignatureProof( - participants=AggregationBits(data=[Boolean(True)]), - proof_data=ByteListMiB(data=b""), + type_name="TypeOneMultiSignature", + value=TypeOneMultiSignature( + info=_info([True], b""), + proof=ByteListMiB(data=b""), ), ) -def test_aggregated_signature_proof_with_data(ssz: SSZTestFiller) -> None: - """SSZ roundtrip for AggregatedSignatureProof with proof data.""" +def test_type_one_multi_signature_with_proof(ssz: SSZTestFiller) -> None: + """SSZ roundtrip for a Type-1 proof with non-empty proof bytes.""" + wire = b"\xde\xad\xbe\xef" ssz( - type_name="AggregatedSignatureProof", - value=AggregatedSignatureProof( - participants=AggregationBits(data=[Boolean(True), Boolean(False), Boolean(True)]), - proof_data=ByteListMiB(data=b"\xde\xad\xbe\xef"), + type_name="TypeOneMultiSignature", + value=TypeOneMultiSignature( + info=_info([True, False, True], wire), + proof=ByteListMiB(data=wire), ), ) -def test_aggregated_signature_proof_multiple_participants(ssz: SSZTestFiller) -> None: - """SSZ roundtrip for AggregatedSignatureProof with five of six participants active.""" +def test_type_two_multi_signature_roundtrip(ssz: SSZTestFiller) -> None: + """SSZ roundtrip for a Type-2 proof binding two messages.""" + wire = b"\x01\x02\x03" + info_a = _info([True], b"\x10\x20") + info_b = _info([False, True], b"\x30\x40") ssz( - type_name="AggregatedSignatureProof", - value=AggregatedSignatureProof( - participants=AggregationBits( - data=[ - Boolean(True), - Boolean(True), - Boolean(True), - Boolean(False), - Boolean(True), - Boolean(True), - ] - ), - proof_data=ByteListMiB(data=b"\x01\x02\x03\x04\x05\x06\x07\x08"), + type_name="TypeTwoMultiSignature", + value=TypeTwoMultiSignature( + info=TypeOneInfos(data=[info_a, info_b]), + proof=ByteListMiB(data=wire), ), ) diff --git a/tests/data/multisig/devnet5_vectors.json b/tests/data/multisig/devnet5_vectors.json new file mode 100644 index 000000000..42c74b5a3 --- /dev/null +++ b/tests/data/multisig/devnet5_vectors.json @@ -0,0 +1,10 @@ +{ + "msg_a": "1111111111111111111111111111111111111111111111111111111111111111", + "msg_b": "2222222222222222222222222222222222222222222222222222222222222222", + "pk_a": "21480b5b140e485a796c3b4602fc7e401c3f563ebaf3cd1b47243a6eff50c43d8f156a35372987434fa7b5546101f431acd68a48", + "pk_b": "93084d4bb57f5050a4e8fe24ae6ef87617b7886d01b28056dd6b7521e977cd06b40b996c2cee935d536c1c143cab974e20a8f822", + "sig_a": "24000000adf24e666ad0fa2c09015e0c306d755d9d621d1a70f19b0ab31fec4f28040000040000003430190f0c0a542fca303a2d7ba14b6b0d495c65b90aba756da7bf02556de306f8b6955d21a9831cd805861d9f8fbf5e09d25a6a7e75553fe878f80f4ce70a4a18b3ea4e2ae2a42ec66aa4446a8ef5694f20c040c33bd14cdd455b4195d2142eb6113473a008826bfc1684470262345eb1321c7005726b69c0b7394ef27f4b6d544b6e75ba982b6168e5a56ea95779640786c536c8dc7250adca771768e9a940188b8332614fda5e6c1969722718b96ee5ce710b913343657ad75d351a648f71101a3a1023c396667f771e416f34ea644e65e30e62f5fb599d60b74b705f4e66adf65c7c6a18925d72f88a33759ac876af861112d26c6b2a6dfa442ea39c6c2d653953504f538e345d77cb4d39ee3432d2793110ee54614e11b55b33cd34b4689e9a484fd1e69062f46b93056d94f8071767e3623073e86f1e3a96236032e077307eb14e486b460f900ce849e14e050de992bf4818715a1e9d1f6a72cd8bfa12f3d8495a1d51aa7c5ee8a137be03ba5d3d252c60deef5c25d3782d4654be74340bb96f1e7710fd0df4cb59605dd15d37235e802aee46d7331bdaab66d12a7b10cbc0d6054ff7a106e1d2a31846e4314832b38155cc6f995b4d748c2fb6725b2177cf1f470b0989091bc13c0c78fc705361d4684d26186d736857415e7a50273aaf52fa0ed259f8719dfc523ecdc632779d277201d9ffee1dbf211679cfaaaf2c2c9fdb207510eb7494a84b5b85e8a204bbe70749fe4dfb085baa14690ee47e019e483013ee7db82920dad057340f180dcbfcdb02e9d0fb6d973afa619315aa46fa073c3f5bf9346c38188f02cde4eb4b7f98440730fbf53120ef1f23d66e844b45a78515ab56934dae0f587341f6ca1a4262651f37436a310a586a60a47aa633b246787358012d48105bb737565d2040f3994d5bd67c2c752c4db65f4ca8bd3d2053b345daedc345628b8c4c1270c017f5ad9e4bbad5c212d99d2c5c6ddb034edd6ca91f4c8c2f0e249c554aa7128377078fc66123bbe624aa02822bdbb77e632b9ea64842167d04b698af035862705e4dbf2651cbb48330be2646407b001b55cf72f0403137b0593a50b8754d8874439bd49f5762e5a51b1f8d85708cbbd535c63e0b42e1a4e56518b4800328b0742e7b7537174fa8d40ea15d59299772617efa8467661cf17d1c113ccd7b32e6da4b5884af0999234023a41cba558c653256f9e1e87dc841e9086fae1a63f189bc4c23c116231220374c9b08836e8cb6b944c6aa9a4a6e8eba1a4d2d4444242c0374b51c043d018015627a9a490bc56c8748122d380f9083c15f58221d39038ade0edd7b3e5baa31153f4bf62832b3db40473d36925258d8353a6b69be539d9d8812457012174d58121078e4203c56a13a05fbccc6595a25f92c5a586e68906acd67de90ef2a3c8cd6453094775a2687c4795eecd84f18f0c666fb6aab7d2fcd5e50298f613f9898ca272a8d4b452223186cfc6c930a70218f3ba499041a57bdbc532a075012374bb556bb05e227b9649773135ca270d81e2d5ad0e0d261d4f6f61fd9165a0a65a67378bd98ef607e69e52cb1f652275976b36e94eea30de8b7ec457a836917623e68724305e36791d2f75548219f7aac449f0f589f412bfa60977ef40dbf020710dd13037ab7715205101a5197fd3af66ce64cee8b360162e063301b9a0e563bf61b72864d335204373f7ae037bc5f23af536a13dafe44e7a8ea0803f41221e7ceea77ff3b0f2817111c3ae47df30b3a90ea08a78ede66894f31202cd5942f1325f47b27285d34fa99da40b7fdc22d4be53a06235a303ce8d89f62cfae7a55d47468249aa1754f3243de4218514903ba22f5037a13a53ab449654f37b15f0b1b3b7e4c9c36f2494dc8ab0342ebd15c5ebce939b2bbbb20cc435a6b322bc33411c5d46af42831312472eb1b8da1603fe0a3a07058e95857a1cab407bc4d161d641079775a4f7f45634f0f00a3c2d1431d1ade6ff6ff2e00e39945292420b36cf1dfc80c88085e1a2eeda96c8313402ab4c28617fdd71a1fecf1d41fd36b5a6f65c27766b884c1425076e971d38a6e6073261104bdee3e1df558766f40749a645f158475b71a44001409a765ff87c4483735696895f82d36cbb74f5336c2fe5b321d5c2c26b2c83188ff755a61eb2f2bde91045b8599f8467ae3d63128e8005485943c0813f200015d0fb9222b8545375e3b0d7bf5b7a93c3812391005544507fd1d46411424ce4df8826943f625970466d724067b8ab950dd739b215911506e082da8151a501c04a3a0bb00e959281dcbc56b3ef93c4a1d5f43bd73c2e97f18b84028490d47df491572a9560f3b960c8c4f1a602397c14fc9e99c5831021d3b953fd235a037817d7ab1c37003a03d283c19bb61e8ad8967d04bcd31adc9316c5fa84b30518f182574ccb5564734a67b731879438293977d04013e5cd285aa45fdd32d3549fb5548e15b1b02485f41390bfadd6cf7992e04f9dc1a5d11ec986590fbaa48e659b274ba401435be25405c491cf477a81eaf1452551a6f2fcf6b7e5650d23f00682d5e3f595662e5f5085216c8c1622acac5455040f96e012cd90fac7c54578ddcd944adcfa1430e67387544637c744eb8b53b50f2876df23f4f651192df792fd9c304020f7306f3349357e84ba212e76dba1dfeca2f0d55c0a30010d5971e74ed7e162ec1237d05a7bf1938675b45a1b9ff24e2d8640a70e43a496f25ce1e3f2aab764ca2fb1b57e96f20e8fccd7abdc0611cc7150733044f264dbae8ea48ad2c3b1838025552a108526c0579e379229e1078c9e4f634b6ec087e8203ae368cd2623594848a7cc3b1202545f7382afd7dc17aabfd090849ad395e4f32ac3fb87722747860766b1e2f4c6af1df760b45a47741c07d52497b00db60ea63615b5f26e816c5c71b558de7aa5c8c86af16302e5e28b0171349575a6276400d7d333853186aae7bc274e4f44a376003755bdf6d49443d15cf6e3233f0197e16264557f8c13984e0de4eeb98cb2e09133b0923de0268aed2116163a7fc126ca7516309adfd49dfbc30618f85416b1228e853ad8051633d7848735a845578537d2947432ef801c64f6756c7cc3064b3e4002843f80222bb935c4b8cc8eb6907ef9006fe6cce5a79c9015572554e5b9b4a77225449d3318d48c3001235ba3bff9a1200b86df42d6f3b717763eef92a7349ce6a62a91059abba5a7cb7a6b405a23741747cd9225c24b4d06907e6da3e22fcb72688385a01309c6526942bed6301cbd2796b279e0d30dd29298fb8bf3a8e73496269627737096c4b6ba8a906223b99f42bd0de721f312ecf62e8611a009c12384de995aa526574be71d164de29feed7060442add46219b020d1087ce2b4a589b677502340577109f6a6380fe7ed5bf216a810bdc1c3b33611e7b07ac093d98ca2fbb3ccb79a779db469ee4152595648e482a594b14e48cdc60b9d57229ef59cc3ad5f2a366bd765075eb494641d6065c169fb88803cb8a450c", + "sig_b": "240000001a2c1959f071a10f0342a72d1a11921dd440952ecc614a562e323c582804000004000000cbe6f444aaabe6414a858952ddbf0c0bd52dc3234234ab48a9694841baf4213777024061a77f5c6b4a516f231e69200df625f03c1c273f35f07ba13f810bfd5d72072816aee2f4574fd86e3599603c5bc3be5d262032462ca49ece33025d0e7efea29b08e7214f736d885e37ffd26351c8bd897a3edf2933df3a8f7c8e50d47976cea92898fbdb6459857060ed7741111d9c0f58d3af8059a50810465618b645ded5655aec52436dddc5934a8e177310b038e34e61dd6a559fe1d867cc3a58488853a651480b0e0b1d1b3814aa3ca004f113a36bc3efdd6dda0ca11b13f019418956066cab968c4500a7967883ba3a4bd8867b27171f560252e1d73249fc7a057686c501fd81c506e1275d79da19b07db4536621ed353e1f2a4f2935fb063308e5a1723e4d80814e2b19ff1b2b6bdb5c2465a522db5aa340fe62414b57b78e021c1dd360a9d617040534a3753a54d75de60b4e5d4b30c31693ebf23b65ccea2f9f7bb747d15fb179b50b026e38792f221c8b5c114ae885088383ec4dda542517f4e1166e9cb2a8330ec85c1fa3651a1a9050171c9e9a597a8735805f653ba56746916412ab87c26b98431642f607dc7a893c840fb20b887414385034cc4a9f2c1f608233a1d50b4cf4d18053fad8b72263d095719a70f241de5b5602c6bf7b469d8ead15fc6f4a7bbd06bc6c6c9d347acf29e118d82ca106193e0d14fec96e784a0ecf19cfb7f24508c2ac2299d5fb613ade3c7a8feff841ac122f48ed0f526b7c4c155fa868b026d837625f05a0a04e06b3c4641879c40c75201717efc6e978ff546b721e7db07cc57bd32b3b4b536eab9f4518e370f848cad8921bf656d24c5212cf6b93fc3c49f09c8a7b8c3e9074d3da951d73d4be124dc8a00072fc043bbbe9a95cb7ddcf769ff1fa1e49ddb826ce6c4f6cbe6c315f883cdf2533c34f3c894a173eb66d6421d789da25a653f62e6bfd4b1c3c65eb0e82f428658a7cc9099184b738d415d05844ba9b1b59f5b80971c96615fc358623916b72620815622d0cb94f2411b23c2e70c4880b972c92504bfaec1bd4b8c05d5535ec10e9bbec32291e66757dd13a04326f4b153c7ae8289306da2c73b14f1a3f7d537375082230f963ef0decb6530f8f79f676d9bc9540893a0b17df3b2a339be9306818c01a3c26e3011e260cb54376c37871caacf429ef8b900466120b65b437a77a2595205a2a641f0e43e190453e003d68f99cd92ab2b57f4ef492a9797909c047c8251b37fc31326e3d33ba1f7da9f3728d7c9e58e7f3cd27568fa350f8bc4b6b583716038b35c35733593935070d936818c76343a44ec90c0c84524f8bbee976ce21a5011b55ae7ce3022c0c097a735cdd25a86515421e58a1411a7983857909606765478dee953031ee8b0ad817b220d1854242a0afb67b7498843e40a23a5fbe702949c17ac93a07ee6965f624f02a41f63e3f6781b87d26ff3a60b926e9541a2df6303bfcf50e5cd3671db78d7f150230925d58ac2c6b83fdb965f6a199325241177072e9b9660ae3046203d625641bdcef6539d8996d48f0b3330339e53c1dac720c592f5a6d154d9605700da22bc34ed748dcfe0859af87d40c3e3b0921caeeaa2108f048441130bf74521c9b4d78d66903ae6dac13e9235e754d41803e242174728129b46f89d39f4bd8b2230b3c45da2054485e3636ce89216af0816818df9b1f5de4211b641f8962dab4bd2560ec302dc592e40a6a44987517273d41e7c4c07a6ee5aa14cac4ee354f871058a2ce504e1f133e3e9d35e75cfbefbc2fb0f72617b709103d5fb0d34bc5472c3916a09650ee3867706ffc6d48b151ea28e0a4b515fb14b1786be9530c0502854c50d47873db827e207167a536a37b947bd8d83d36a09dc90ab494a7579113920426b276368fb33b07a88f7b6aba8ba346a1134d14d8459c2af8b1fe4d57d79569515108352c1c4140594e6f48974f8d7a67f6e209492f28597b2ada1a00d16367095a87218bc812615467354fd9080127ce243c45ce273c27e93e1d096fbdce29c69b700563efb06708707e171100186db1e52a1b647cda2dd1e55b07139bc221571c41175042e22f73472e2ad89da77d18ee6f77ae55e939a9f1a922a51091333bca6a4fd04be001af0c1614707f24016b6bd004105a95616f30592f3b6cb44753ce7326f1408c2d6cfe4f097ce1b35ee00a2848ce0910609ef52a580982833ce18bd94c43e19e283455b66550c385190a64ab5622b47b2c4f05cc6b32e3fb470bcb195c72900b7dad421849b3e8db1d19d4b8168988c156fbf2eb7287991d3a8ec3a918d219176c69519c4c26f2ca3c5466dd484143cc7d75cb28119be8f26894aa2829c4ae6c41edf4327131c8ee034c9b9a4136991f54c541334a7122ea223e109c0f7ba16c52ce05316556e1f0532bce76417d14201b786bde18cd1db1720f84af7037203a4d339aad050684bf5027340d36072b2d21742ebc72c44bef0a683e1a2108cdb66ec0bf250075aeec62c514d63db09cb37ba6e4d77bf852171a3f0b1037ad082515a66fb15d6d5cf409f9fcd1420c72e315857c011a6c921b365d9a2911b986044411fad44797ae494ebf2b245d105b265e822be84a2031eb31372dde5fcfb09e6272b6b31f643baf1a2accd53f77ee7d15b1e04828c9562c776812326f36f2d109cb2ab12b598d6573765f771db6b31523678b551515e59e4f82658c1a8e0af65fc5a5c26b8e902c074320d335eb583a41dcff6528fecffb62f0a669415af3f33e17f0fd59bc51d2170d7b696666b595422a8a2212c88f785122ef83369468eb5eea5fdd3f6ec5127d85d6051dc36910762040cb2eb281975929f2c91364a4910662db1221e48343774991b80aa3ad4a2426bfac3b9e776e4d3c333c564ad3070e39815d6687e4a86b62814119e203823fb3bb5d34b7a04314ce642a673174c601e8352f1134067829b1970d277ea89b36694cf122027db85d30724d673b7da52ee99cd27197cd3830c0e3420b8503af0bc9207f54063b4d579c976d129083583f3ceb2e46a60f7c71aacf0e63e9ae786511cedd027c08f82d49332d78d2729219b5efd8242fa4af59e9693b64ac63c257e95e7a658c71cc7890614f1f63a3e902c3becb4223a1f21e40c5a936afb4414dfbc2c83bae87f46ff21dbb3f68fd62779b4ada4ba4759e43d99ab3760470b0243e842b760636f04b09f7233168095805653b741463122f7461f70b50375b28499cce042d467dfa5da6e6cb7ec7e9d657d618963f60102222d9bdbe5f1dd5c53ae70d631f336a8d20bcde1b62f90b1c41741c20429260dd6f420bb5514592b94dc5c3352c7c160575faf94e19665e615fcd6dc306c8da0967fcba43120279fb52f6f3bb28a136df488cd4557033f3d40d82b7d82413397e7a35c08a388668565bdec4350b53b7d579cb24be1cef01636855a3ff2ed186193c3755304afedb797b53bf593f0afd860c872ec124dcfde46bcb1c8c67d0678027c7aad602", + "slot_a": 5, + "slot_b": 6 +} diff --git a/tests/lean_spec/forks/lstar/forkchoice/test_attestation_target.py b/tests/lean_spec/forks/lstar/forkchoice/test_attestation_target.py index 8cdee3fbd..a1ce868f6 100644 --- a/tests/lean_spec/forks/lstar/forkchoice/test_attestation_target.py +++ b/tests/lean_spec/forks/lstar/forkchoice/test_attestation_target.py @@ -12,13 +12,19 @@ SignedBlock, ) from lean_spec.forks.lstar.containers.attestation import SignedAttestation -from lean_spec.forks.lstar.containers.block import BlockSignatures -from lean_spec.forks.lstar.containers.block.types import AttestationSignatures from lean_spec.forks.lstar.spec import LstarSpec from lean_spec.subspecs.chain.clock import Interval from lean_spec.subspecs.chain.config import JUSTIFICATION_LOOKBACK_SLOTS from lean_spec.subspecs.ssz.hash import hash_tree_root -from lean_spec.types import Bytes32, Checkpoint, Slot, ValidatorIndex +from lean_spec.subspecs.xmss.aggregation import TypeOneMultiSignature, TypeTwoMultiSignature +from lean_spec.types import ( + ByteListMiB, + Bytes32, + Checkpoint, + Slot, + ValidatorIndex, + ValidatorIndices, +) from tests.lean_spec.helpers import make_store @@ -569,15 +575,35 @@ def test_attestation_target_after_on_block( store, block, signatures = spec.produce_block_with_signatures(store, slot_1, proposer_1) block_root = hash_tree_root(block) - # Sign the block root with the proposal key + # Wrap the proposer's signature into a singleton Type-1, then merge + # with the per-attestation Type-1s into the block-level Type-2. proposer_signature = key_manager.sign_block_root(proposer_1, slot_1, block_root) + proposer_pubkey = key_manager.get_public_keys(proposer_1)[1] + proposer_type_1 = TypeOneMultiSignature.aggregate( + children=[], + raw_xmss=[(proposer_pubkey, proposer_signature)], + xmss_participants=ValidatorIndices(data=[proposer_1]).to_aggregation_bits(), + message=block_root, + slot=slot_1, + ) + head_state = store.states[store.head] + public_keys_per_part: list[list] = [ + [ + head_state.validators[vid].get_attestation_pubkey() + for vid in proof.info.participants.to_validator_indices() + ] + for proof in signatures + ] + public_keys_per_part.append([proposer_pubkey]) + + merged = TypeTwoMultiSignature.aggregate( + [*signatures, proposer_type_1], + public_keys_per_part=public_keys_per_part, + ) signed_block = SignedBlock( block=block, - signature=BlockSignatures( - attestation_signatures=AttestationSignatures(data=signatures), - proposer_signature=proposer_signature, - ), + proof=ByteListMiB(data=merged.encode_bytes()), ) # Process block via on_block on a fresh consumer store diff --git a/tests/lean_spec/forks/lstar/forkchoice/test_compute_block_weights.py b/tests/lean_spec/forks/lstar/forkchoice/test_compute_block_weights.py index 94dcfa4b6..9803ff255 100644 --- a/tests/lean_spec/forks/lstar/forkchoice/test_compute_block_weights.py +++ b/tests/lean_spec/forks/lstar/forkchoice/test_compute_block_weights.py @@ -6,17 +6,21 @@ from lean_spec.forks.lstar.containers.attestation import AttestationData from lean_spec.forks.lstar.spec import LstarSpec from lean_spec.subspecs.ssz.hash import hash_tree_root -from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof +from lean_spec.subspecs.xmss.aggregation import TypeOneInfo, TypeOneMultiSignature from lean_spec.types import Checkpoint, Slot, ValidatorIndex, ValidatorIndices from lean_spec.types.byte_arrays import ByteListMiB from tests.lean_spec.helpers import make_bytes32, make_signed_block -def _make_empty_proof(participants: list[ValidatorIndex]) -> AggregatedSignatureProof: - """Create an aggregated proof with empty proof data for testing.""" - return AggregatedSignatureProof( - participants=ValidatorIndices(data=participants).to_aggregation_bits(), - proof_data=ByteListMiB(data=b""), +def _make_empty_proof(participants: list[ValidatorIndex]) -> TypeOneMultiSignature: + """Create a placeholder Type-1 proof carrying a participant bitfield.""" + placeholder = ByteListMiB(data=b"") + return TypeOneMultiSignature( + info=TypeOneInfo( + participants=ValidatorIndices(data=participants).to_aggregation_bits(), + proof=placeholder, + ), + proof=placeholder, ) diff --git a/tests/lean_spec/forks/lstar/forkchoice/test_store_attestations.py b/tests/lean_spec/forks/lstar/forkchoice/test_store_attestations.py index f2c50561d..06ddb44bb 100644 --- a/tests/lean_spec/forks/lstar/forkchoice/test_store_attestations.py +++ b/tests/lean_spec/forks/lstar/forkchoice/test_store_attestations.py @@ -15,7 +15,7 @@ from lean_spec.subspecs.chain.clock import Interval from lean_spec.subspecs.chain.config import INTERVALS_PER_SLOT from lean_spec.subspecs.ssz.hash import hash_tree_root -from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof +from lean_spec.subspecs.xmss.aggregation import TypeOneMultiSignature from lean_spec.types import ( ByteListMiB, Bytes32, @@ -302,10 +302,10 @@ def test_valid_proof_stored_correctly( strict=True, ) ) - proof = AggregatedSignatureProof.aggregate( - xmss_participants=xmss_participants, + proof = TypeOneMultiSignature.aggregate( children=[], raw_xmss=raw_xmss, + xmss_participants=xmss_participants, message=data_root, slot=attestation_data.slot, ) @@ -349,10 +349,10 @@ def test_attestation_data_used_as_key( strict=True, ) ) - proof = AggregatedSignatureProof.aggregate( - xmss_participants=xmss_participants, + proof = TypeOneMultiSignature.aggregate( children=[], raw_xmss=raw_xmss, + xmss_participants=xmss_participants, message=data_root, slot=attestation_data.slot, ) @@ -389,22 +389,21 @@ def test_invalid_proof_rejected(self, key_manager: XmssKeyManager, spec: LstarSp strict=True, ) ) - proof = AggregatedSignatureProof.aggregate( - xmss_participants=xmss_participants, + proof = TypeOneMultiSignature.aggregate( children=[], raw_xmss=raw_xmss, + xmss_participants=xmss_participants, message=data_root, slot=attestation_data.slot, ) - # Corrupt the proof data - corrupted_data = bytearray(proof.proof_data.encode_bytes()) - corrupted_data[10] ^= 0xFF - corrupted_data[20] ^= 0xFF - corrupted_proof = AggregatedSignatureProof( - participants=proof.participants, - proof_data=ByteListMiB(data=bytes(corrupted_data)), - ) + # Corrupt the proof bytes so the binding rejects the proof. + corrupted_bytes = bytearray(proof.proof.data) + corrupted_bytes[10] ^= 0xFF + corrupted_bytes[20] ^= 0xFF + corrupted_blob = ByteListMiB(data=bytes(corrupted_bytes)) + corrupted_info = proof.info.model_copy(update={"proof": corrupted_blob}) + corrupted_proof = TypeOneMultiSignature(info=corrupted_info, proof=corrupted_blob) signed_aggregated = SignedAggregatedAttestation( data=attestation_data, @@ -440,10 +439,10 @@ def test_multiple_proofs_accumulate(self, key_manager: XmssKeyManager, spec: Lst strict=True, ) ) - proof_1 = AggregatedSignatureProof.aggregate( - xmss_participants=xmss_1, + proof_1 = TypeOneMultiSignature.aggregate( children=[], raw_xmss=raw_xmss_1, + xmss_participants=xmss_1, message=data_root, slot=attestation_data.slot, ) @@ -461,10 +460,10 @@ def test_multiple_proofs_accumulate(self, key_manager: XmssKeyManager, spec: Lst strict=True, ) ) - proof_2 = AggregatedSignatureProof.aggregate( - xmss_participants=xmss_2, + proof_2 = TypeOneMultiSignature.aggregate( children=[], raw_xmss=raw_xmss_2, + xmss_participants=xmss_2, message=data_root, slot=attestation_data.slot, ) @@ -542,16 +541,8 @@ def test_aggregated_proof_is_valid(self, key_manager: XmssKeyManager, spec: Lsta proofs = updated_store.latest_new_aggregated_payloads[attestation_data] proof = next(iter(proofs)) - # Extract participants from the proof - participants = proof.participants.to_validator_indices() - public_keys = [key_manager[vid].attestation_public for vid in participants] - - # Verify the proof is valid - proof.verify( - public_keys=public_keys, - message=hash_tree_root(attestation_data), - slot=attestation_data.slot, - ) + # Structural binding: proof info matches the attestation data participants. + assert set(proof.info.participants.to_validator_indices()) == set(attesting_validators) def test_empty_attestation_signatures_produces_no_proofs( self, key_manager: XmssKeyManager, spec: LstarSpec @@ -774,7 +765,6 @@ def test_gossip_to_aggregation_to_storage( store = store.model_copy(update={"time": Interval.from_slot(Slot(1))}) attestation_data = spec.produce_attestation_data(store, Slot(1)) - data_root = hash_tree_root(attestation_data) # Step 1: Receive gossip attestations from validators 1 and 2 # (all in same subnet since ATTESTATION_COMMITTEE_COUNT=1 by default) @@ -807,13 +797,6 @@ def test_gossip_to_aggregation_to_storage( "Aggregated proofs should exist after interval 2" ) - # Step 4: Verify the proof is valid + # Step 4: Structural check that the proof binds to the expected data participants. proof = next(iter(store.latest_new_aggregated_payloads[attestation_data])) - participants = proof.participants.to_validator_indices() - public_keys = [key_manager[vid].attestation_public for vid in participants] - - proof.verify( - public_keys=public_keys, - message=data_root, - slot=attestation_data.slot, - ) + assert proof.info.participants.to_validator_indices() diff --git a/tests/lean_spec/forks/lstar/forkchoice/test_store_pruning.py b/tests/lean_spec/forks/lstar/forkchoice/test_store_pruning.py index c5ed7cb97..cff736ede 100644 --- a/tests/lean_spec/forks/lstar/forkchoice/test_store_pruning.py +++ b/tests/lean_spec/forks/lstar/forkchoice/test_store_pruning.py @@ -2,7 +2,7 @@ from lean_spec.forks.lstar import AttestationSignatureEntry, Store from lean_spec.forks.lstar.spec import LstarSpec -from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof +from lean_spec.subspecs.xmss.aggregation import TypeOneInfo, TypeOneMultiSignature from lean_spec.types import ByteListMiB, Bytes32, Slot, ValidatorIndex, ValidatorIndices from tests.lean_spec.helpers import ( make_attestation_data, @@ -136,10 +136,14 @@ def test_prunes_related_structures_together(spec: LstarSpec, pruning_store: Stor source_root=make_bytes32(255), ) - # Create mock aggregated proof (empty proof data for testing) - mock_proof = AggregatedSignatureProof( - participants=ValidatorIndices(data=[ValidatorIndex(1)]).to_aggregation_bits(), - proof_data=ByteListMiB(data=b""), + # Create mock aggregated proof (empty proof bytes for testing) + placeholder = ByteListMiB(data=b"") + mock_proof = TypeOneMultiSignature( + info=TypeOneInfo( + participants=ValidatorIndices(data=[ValidatorIndex(1)]).to_aggregation_bits(), + proof=placeholder, + ), + proof=placeholder, ) # Set up store with both stale and fresh entries in all structures diff --git a/tests/lean_spec/forks/lstar/forkchoice/test_time_management.py b/tests/lean_spec/forks/lstar/forkchoice/test_time_management.py index 572fe03e8..08fc23b21 100644 --- a/tests/lean_spec/forks/lstar/forkchoice/test_time_management.py +++ b/tests/lean_spec/forks/lstar/forkchoice/test_time_management.py @@ -1,7 +1,6 @@ """Tests for time advancement, intervals, and slot management.""" -from hypothesis import given, settings -from hypothesis import strategies as st +from hypothesis import given, settings, strategies as st from lean_spec.forks.lstar import Store from lean_spec.forks.lstar.containers import Block diff --git a/tests/lean_spec/forks/lstar/forkchoice/test_validator.py b/tests/lean_spec/forks/lstar/forkchoice/test_validator.py index af62dfbf3..0816220da 100644 --- a/tests/lean_spec/forks/lstar/forkchoice/test_validator.py +++ b/tests/lean_spec/forks/lstar/forkchoice/test_validator.py @@ -15,7 +15,7 @@ from lean_spec.forks.lstar.spec import LstarSpec from lean_spec.subspecs.chain.clock import Interval from lean_spec.subspecs.ssz.hash import hash_tree_root -from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof +from lean_spec.subspecs.xmss.aggregation import TypeOneMultiSignature from lean_spec.types import Bytes32, Checkpoint, Slot, Uint64, ValidatorIndex from tests.lean_spec.helpers import TEST_VALIDATOR_ID, make_aggregated_proof, make_store @@ -89,7 +89,7 @@ def test_produce_block_with_attestations( # Build payloads keyed by attestation data. # If data_5 == data_6 (same slot/head/target/source), they share a key. - known_payloads: dict[AttestationData, set[AggregatedSignatureProof]] = {} + known_payloads: dict[AttestationData, set[TypeOneMultiSignature]] = {} known_payloads.setdefault(signed_5.data, set()).add(proof_5) known_payloads.setdefault(signed_6.data, set()).add(proof_6) @@ -126,15 +126,9 @@ def test_produce_block_with_attestations( assert block.proposer_index == validator_idx assert block.state_root != Bytes32.zero() - # Verify each aggregated signature proof + # Verify each aggregated proof binds to its attestation in the block. for agg_att, proof in zip(block.body.attestations.data, signatures, strict=True): - participants = proof.participants.to_validator_indices() - public_keys = [key_manager[vid].attestation_public for vid in participants] - proof.verify( - public_keys=public_keys, - message=hash_tree_root(agg_att.data), - slot=agg_att.data.slot, - ) + assert proof.info.participants == agg_att.aggregation_bits def test_produce_block_sequential_slots(self, sample_store: Store, spec: LstarSpec) -> None: """Test producing blocks in sequential slots.""" @@ -247,15 +241,9 @@ def test_produce_block_state_consistency( stored_state = store.states[block_hash] assert hash_tree_root(stored_state) == block.state_root - # Verify each aggregated signature proof + # Verify each aggregated proof binds to its attestation in the block. for agg_att, proof in zip(block.body.attestations.data, signatures, strict=True): - participants = proof.participants.to_validator_indices() - public_keys = [key_manager[vid].attestation_public for vid in participants] - proof.verify( - public_keys=public_keys, - message=hash_tree_root(agg_att.data), - slot=agg_att.data.slot, - ) + assert proof.info.participants == agg_att.aggregation_bits class TestValidatorIntegration: diff --git a/tests/lean_spec/forks/lstar/state/test_state_aggregation.py b/tests/lean_spec/forks/lstar/state/test_state_aggregation.py index 5b727e73b..e4dba74a2 100644 --- a/tests/lean_spec/forks/lstar/state/test_state_aggregation.py +++ b/tests/lean_spec/forks/lstar/state/test_state_aggregation.py @@ -25,7 +25,6 @@ def test_aggregated_signatures_prefers_full_gossip_payload( spec: LstarSpec, ) -> None: store = make_store(num_validators=2, key_manager=container_key_manager) - head_state = store.states[store.head] source = Checkpoint(root=make_bytes32(1), slot=Slot(0)) att_data = make_attestation_data_simple( Slot(2), make_bytes32(3), make_bytes32(4), source=source @@ -44,20 +43,11 @@ def test_aggregated_signatures_prefers_full_gossip_payload( _, results = spec.aggregate(store) assert len(results) == 1 - assert set(results[0].proof.participants.to_validator_indices()) == { + assert set(results[0].proof.info.participants.to_validator_indices()) == { ValidatorIndex(0), ValidatorIndex(1), } - public_keys = [ - head_state.validators[ValidatorIndex(i)].get_attestation_pubkey() for i in range(2) - ] - results[0].proof.verify( - public_keys=public_keys, - message=hash_tree_root(att_data), - slot=att_data.slot, - ) - def test_build_block_collects_valid_available_attestations( container_key_manager: XmssKeyManager, @@ -76,7 +66,6 @@ def test_build_block_collects_valid_available_attestations( target=target, source=source, ) - data_root = hash_tree_root(att_data) proof = make_aggregated_proof(container_key_manager, [ValidatorIndex(0)], att_data) aggregated_payloads = {att_data: {proof}} @@ -93,19 +82,13 @@ def test_build_block_collects_valid_available_attestations( assert post_state.latest_block_header.slot == Slot(1) assert list(block.body.attestations.data) == aggregated_atts assert len(aggregated_proofs) == 1 - assert aggregated_proofs[0].participants.to_validator_indices() == ValidatorIndices( + assert aggregated_proofs[0].info.participants.to_validator_indices() == ValidatorIndices( data=[ValidatorIndex(0)] ) assert block.body.attestations.data[0].aggregation_bits.to_validator_indices() == ( ValidatorIndices(data=[ValidatorIndex(0)]) ) - aggregated_proofs[0].verify( - public_keys=[container_key_manager[ValidatorIndex(0)].attestation_public], - message=data_root, - slot=att_data.slot, - ) - def test_build_block_skips_attestations_without_signatures( container_key_manager: XmssKeyManager, @@ -149,7 +132,6 @@ def test_aggregated_signatures_with_multiple_data_groups( ) -> None: """Multiple attestation data groups should be processed independently.""" store = make_store(num_validators=4, key_manager=container_key_manager) - head_state = store.states[store.head] source = Checkpoint(root=make_bytes32(22), slot=Slot(0)) att_data1 = make_attestation_data_simple( Slot(9), make_bytes32(23), make_bytes32(24), source=source @@ -187,13 +169,7 @@ def test_aggregated_signatures_with_multiple_data_groups( assert len(results) == 2 for signed_att in results: - participants = signed_att.proof.participants.to_validator_indices() - public_keys = [head_state.validators[vid].get_attestation_pubkey() for vid in participants] - signed_att.proof.verify( - public_keys=public_keys, - message=hash_tree_root(signed_att.data), - slot=signed_att.data.slot, - ) + assert signed_att.proof.info.participants.to_validator_indices() def test_build_block_state_root_valid_when_signatures_split( diff --git a/tests/lean_spec/helpers/builders.py b/tests/lean_spec/helpers/builders.py index d2a9b4648..cb1c412f2 100644 --- a/tests/lean_spec/helpers/builders.py +++ b/tests/lean_spec/helpers/builders.py @@ -24,11 +24,7 @@ AggregatedAttestation, SignedAggregatedAttestation, ) -from lean_spec.forks.lstar.containers.block import BlockSignatures -from lean_spec.forks.lstar.containers.block.types import ( - AggregatedAttestations, - AttestationSignatures, -) +from lean_spec.forks.lstar.containers.block.types import AggregatedAttestations from lean_spec.forks.lstar.containers.state import Validators from lean_spec.forks.lstar.spec import LstarSpec from lean_spec.subspecs.chain.clock import Interval, SlotClock @@ -42,7 +38,7 @@ from lean_spec.subspecs.sync.block_cache import BlockCache from lean_spec.subspecs.sync.peer_manager import PeerManager from lean_spec.subspecs.sync.service import SyncService -from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof +from lean_spec.subspecs.xmss.aggregation import TypeOneMultiSignature, TypeTwoMultiSignature from lean_spec.subspecs.xmss.constants import TARGET_CONFIG from lean_spec.subspecs.xmss.containers import Signature from lean_spec.subspecs.xmss.types import ( @@ -52,6 +48,7 @@ Randomness, ) from lean_spec.types import ( + ByteListMiB, Bytes32, Bytes52, Checkpoint, @@ -213,7 +210,11 @@ def make_signed_block( parent_root: Bytes32, state_root: Bytes32, ) -> SignedBlock: - """Create a signed block with minimal valid structure.""" + """Create a signed block with minimal valid structure and an empty proof blob. + + The empty proof carries no cryptographic content. Tests that exercise real + verification should build the block via the spec filler instead. + """ block = Block( slot=slot, proposer_index=proposer_index, @@ -222,13 +223,7 @@ def make_signed_block( body=BlockBody(attestations=AggregatedAttestations(data=[])), ) - return SignedBlock( - block=block, - signature=BlockSignatures( - attestation_signatures=AttestationSignatures(data=[]), - proposer_signature=make_mock_signature(), - ), - ) + return SignedBlock(block=block, proof=ByteListMiB(data=b"")) def make_aggregated_attestation( @@ -430,8 +425,13 @@ def make_aggregated_proof( key_manager: XmssKeyManager, participants: list[ValidatorIndex], attestation_data: AttestationData, -) -> AggregatedSignatureProof: - """Create a valid aggregated signature proof for the given participants.""" +) -> TypeOneMultiSignature: + """Create a valid Type-1 aggregated proof for the given participants. + + Produces a real cryptographic proof because the resulting Type-1 + typically feeds into production aggregation (build_block compaction, + on_block verification), which rejects empty proof bytes. + """ data_root = hash_tree_root(attestation_data) xmss_participants = ValidatorIndices(data=participants).to_aggregation_bits() raw_xmss = list( @@ -441,10 +441,10 @@ def make_aggregated_proof( strict=True, ) ) - return AggregatedSignatureProof.aggregate( - xmss_participants=xmss_participants, + return TypeOneMultiSignature.aggregate( children=[], raw_xmss=raw_xmss, + xmss_participants=xmss_participants, message=data_root, slot=attestation_data.slot, ) @@ -480,18 +480,43 @@ def make_signed_block_from_store( """Produce a signed block and advance the consumer store to accept it. Returns the updated store (with time advanced) and the signed block. + The merged Type-2 proof is built honestly because callers usually + feed the result through spec.on_block, which decodes and verifies + the proof. """ - _, block, _ = LstarSpec().produce_block_with_signatures(store, slot, proposer_index) + new_store, block, attestation_proofs = LstarSpec().produce_block_with_signatures( + store, slot, proposer_index + ) block_root = hash_tree_root(block) + + head_state = new_store.states[new_store.head] + public_keys_per_part: list[list] = [ + [ + head_state.validators[vid].get_attestation_pubkey() + for vid in proof.info.participants.to_validator_indices() + ] + for proof in attestation_proofs + ] + proposer_pubkey = head_state.validators[proposer_index].get_proposal_pubkey() + public_keys_per_part.append([proposer_pubkey]) + proposer_signature = key_manager.sign_block_root(proposer_index, slot, block_root) - attestation_signatures = key_manager.build_attestation_signatures(block.body.attestations) + proposer_type_1 = TypeOneMultiSignature.aggregate( + children=[], + raw_xmss=[(proposer_pubkey, proposer_signature)], + xmss_participants=ValidatorIndices(data=[proposer_index]).to_aggregation_bits(), + message=block_root, + slot=slot, + ) + + merged = TypeTwoMultiSignature.aggregate( + [*attestation_proofs, proposer_type_1], + public_keys_per_part=public_keys_per_part, + ) signed_block = SignedBlock( block=block, - signature=BlockSignatures( - attestation_signatures=attestation_signatures, - proposer_signature=proposer_signature, - ), + proof=ByteListMiB(data=merged.encode_bytes()), ) target_interval = Interval.from_slot(block.slot) diff --git a/tests/lean_spec/subspecs/networking/client/test_reqresp_client_range.py b/tests/lean_spec/subspecs/networking/client/test_reqresp_client_range.py index 7cf1f464d..85ae2d244 100644 --- a/tests/lean_spec/subspecs/networking/client/test_reqresp_client_range.py +++ b/tests/lean_spec/subspecs/networking/client/test_reqresp_client_range.py @@ -12,11 +12,7 @@ BlockBody, SignedBlock, ) -from lean_spec.forks.lstar.containers.block import BlockSignatures -from lean_spec.forks.lstar.containers.block.types import ( - AggregatedAttestations, - AttestationSignatures, -) +from lean_spec.forks.lstar.containers.block.types import AggregatedAttestations from lean_spec.subspecs.networking.client.reqresp_client import ReqRespClient from lean_spec.subspecs.networking.config import MAX_REQUEST_BLOCKS from lean_spec.subspecs.networking.reqresp.codec import ( @@ -28,8 +24,7 @@ ) from lean_spec.subspecs.networking.transport import PeerId from lean_spec.subspecs.ssz.hash import hash_tree_root -from lean_spec.types import Bytes32, Slot, Uint64, ValidatorIndex -from tests.lean_spec.helpers import make_mock_signature +from lean_spec.types import ByteListMiB, Bytes32, Slot, Uint64, ValidatorIndex @dataclass @@ -119,13 +114,7 @@ def empty_signed_block(slot: Slot, parent_root: Bytes32, state_seed: int) -> Sig state_root=Bytes32(bytes([state_seed]) * 32), body=BlockBody(attestations=AggregatedAttestations(data=[])), ) - return SignedBlock( - block=block, - signature=BlockSignatures( - attestation_signatures=AttestationSignatures(data=[]), - proposer_signature=make_mock_signature(), - ), - ) + return SignedBlock(block=block, proof=ByteListMiB(data=b"")) def build_chain(start_slot: int, count: int, root_seed: int = 0xAA) -> list[SignedBlock]: diff --git a/tests/lean_spec/subspecs/observability/test_observer.py b/tests/lean_spec/subspecs/observability/test_observer.py index 79c905fa9..beb425493 100644 --- a/tests/lean_spec/subspecs/observability/test_observer.py +++ b/tests/lean_spec/subspecs/observability/test_observer.py @@ -9,8 +9,7 @@ import pytest from prometheus_client import CollectorRegistry, Histogram -from lean_spec.subspecs.metrics import PrometheusObserver -from lean_spec.subspecs.metrics import registry as metrics +from lean_spec.subspecs.metrics import PrometheusObserver, registry as metrics from lean_spec.subspecs.observability import ( NullObserver, get_observer, diff --git a/tests/lean_spec/subspecs/ssz/test_block.py b/tests/lean_spec/subspecs/ssz/test_block.py index 53a1e615b..2b046460b 100644 --- a/tests/lean_spec/subspecs/ssz/test_block.py +++ b/tests/lean_spec/subspecs/ssz/test_block.py @@ -1,15 +1,6 @@ -from lean_spec.forks.lstar.containers.block import ( - Block, - BlockBody, - BlockSignatures, - SignedBlock, -) -from lean_spec.forks.lstar.containers.block.types import ( - AggregatedAttestations, - AttestationSignatures, -) -from lean_spec.types import Bytes32, Slot, ValidatorIndex -from tests.lean_spec.helpers.builders import make_mock_signature +from lean_spec.forks.lstar.containers.block import Block, BlockBody, SignedBlock +from lean_spec.forks.lstar.containers.block.types import AggregatedAttestations +from lean_spec.types import ByteListMiB, Bytes32, Slot, ValidatorIndex def test_encode_decode_signed_block_roundtrip() -> None: @@ -21,13 +12,7 @@ def test_encode_decode_signed_block_roundtrip() -> None: body=BlockBody(attestations=AggregatedAttestations(data=[])), ) - signed_block = SignedBlock( - block=block, - signature=BlockSignatures( - attestation_signatures=AttestationSignatures(data=[]), - proposer_signature=make_mock_signature(), - ), - ) + signed_block = SignedBlock(block=block, proof=ByteListMiB(data=b"")) encode = signed_block.encode_bytes() decoded = SignedBlock.decode_bytes(encode) diff --git a/tests/lean_spec/subspecs/sync/test_checkpoint_sync.py b/tests/lean_spec/subspecs/sync/test_checkpoint_sync.py index 4ccfdb996..75ce7ee5d 100644 --- a/tests/lean_spec/subspecs/sync/test_checkpoint_sync.py +++ b/tests/lean_spec/subspecs/sync/test_checkpoint_sync.py @@ -8,9 +8,7 @@ import pytest from lean_spec.forks import ( - AttestationSignatures, Block, - BlockSignatures, SignedBlock, ) from lean_spec.forks.lstar import State, Store @@ -27,8 +25,7 @@ fetch_finalized_state, verify_checkpoint_state, ) -from lean_spec.types import Bytes32, Slot -from tests.lean_spec.helpers import make_mock_signature +from lean_spec.types import ByteListMiB, Bytes32, Slot class _MockTransport(httpx.AsyncBaseTransport): @@ -234,19 +231,13 @@ async def test_client_fetches_and_deserializes_state(self, base_store: Store) -> def _wrap_as_signed_block(block: Block) -> SignedBlock: - """Build a SignedBlock around a Block using a mock signature. + """Build a SignedBlock around a Block using an empty proof envelope. The spec retains only Block in Store; tests need a SignedBlock for the - ``signed_block_getter`` callable, so we construct one with empty - attestation signatures and a mock proposer signature. + signed-block getter callable. An empty proof is sufficient for these + structural checks, which do not exercise cryptographic verification. """ - return SignedBlock( - block=block, - signature=BlockSignatures( - attestation_signatures=AttestationSignatures(data=[]), - proposer_signature=make_mock_signature(), - ), - ) + return SignedBlock(block=block, proof=ByteListMiB(data=b"")) class TestFetchFinalizedBlock: diff --git a/tests/lean_spec/subspecs/validator/test_service.py b/tests/lean_spec/subspecs/validator/test_service.py index 5a0c067fa..c778d0e78 100644 --- a/tests/lean_spec/subspecs/validator/test_service.py +++ b/tests/lean_spec/subspecs/validator/test_service.py @@ -25,7 +25,7 @@ from lean_spec.subspecs.validator.constants import SYNC_LAG_THRESHOLD from lean_spec.subspecs.validator.registry import ValidatorEntry from lean_spec.subspecs.xmss import TARGET_SIGNATURE_SCHEME -from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof +from lean_spec.subspecs.xmss.aggregation import TypeOneMultiSignature, TypeTwoMultiSignature from lean_spec.types import Bytes32, Slot, Uint64, ValidatorIndex, ValidatorIndices from tests.lean_spec.helpers import ( TEST_VALIDATOR_ID, @@ -127,51 +127,46 @@ def test_wraps_the_input_block( assert result.block is block - def test_proposer_signature_is_cryptographically_valid( + def test_envelope_binds_proposer_entry( self, sync_service: SyncService, key_manager: XmssKeyManager ) -> None: - """The proposer signature verifies with the proposer's public key.""" + """The merged Type-2 proof binds an info entry for the proposer's block-root sig.""" service, block = self._setup(sync_service, key_manager) result = service._sign_block(block, ValidatorIndex(0), []) - public_key = key_manager[ValidatorIndex(0)].proposal_public - is_valid = TARGET_SIGNATURE_SCHEME.verify( - pk=public_key, - slot=block.slot, - message=hash_tree_root(block), - sig=result.signature.proposer_signature, - ) - assert is_valid + decoded = TypeTwoMultiSignature.decode_bytes(result.proof.data) + assert len(decoded.info) == 1 + proposer_entry = decoded.info[0] + assert set(proposer_entry.participants.to_validator_indices()) == {ValidatorIndex(0)} - def test_signs_block_root_with_proposal_key( + def test_envelope_uses_proposal_slot( self, sync_service: SyncService, key_manager: XmssKeyManager ) -> None: - """Block signing uses the proposal key and the signature covers the block root.""" + """The merged envelope decodes cleanly for a proposer at a non-genesis slot.""" service, block = self._setup(sync_service, key_manager, slot=2) result = service._sign_block(block, ValidatorIndex(0), []) - # Signature must verify against the proposal public key (not attestation key). - proposal_pk = key_manager[ValidatorIndex(0)].proposal_public - assert TARGET_SIGNATURE_SCHEME.verify( - pk=proposal_pk, - slot=Slot(2), - message=hash_tree_root(block), - sig=result.signature.proposer_signature, - ) + decoded = TypeTwoMultiSignature.decode_bytes(result.proof.data) + assert len(decoded.info) == 1 - def test_attestation_signatures_included( + def test_attestation_proofs_appear_first_in_merged_info( self, sync_service: SyncService, key_manager: XmssKeyManager, spec: LstarSpec ) -> None: - """Aggregated attestation proofs passed in are present in the returned signature.""" + """Attestation proofs land before the proposer entry in the merged info list.""" service, block = self._setup(sync_service, key_manager) attestation_data = spec.produce_attestation_data(sync_service.store, Slot(1)) agg_proof = make_aggregated_proof(key_manager, [ValidatorIndex(0)], attestation_data) result = service._sign_block(block, ValidatorIndex(0), [agg_proof]) - assert agg_proof in list(result.signature.attestation_signatures) + decoded = TypeTwoMultiSignature.decode_bytes(result.proof.data) + assert len(decoded.info) == 2 + # Attestation entry: participants match the aggregated proof. + assert decoded.info[0].participants == agg_proof.info.participants + # Proposer entry: singleton bitfield for the proposer. + assert set(decoded.info[1].participants.to_validator_indices()) == {ValidatorIndex(0)} def test_missing_validator_raises_value_error( self, sync_service: SyncService, key_manager: XmssKeyManager @@ -976,17 +971,12 @@ async def capture_block(block: SignedBlock) -> None: assert signed_block.block.slot == Slot(1) assert signed_block.block.proposer_index == ValidatorIndex(1) - proposer_index = signed_block.block.proposer_index - block_root = hash_tree_root(signed_block.block) - proposer_public_key = key_manager[proposer_index].proposal_public - - is_valid = TARGET_SIGNATURE_SCHEME.verify( - pk=proposer_public_key, - slot=signed_block.block.slot, - message=block_root, - sig=signed_block.signature.proposer_signature, - ) - assert is_valid, "Proposer signature failed verification" + # The merged proof must bind a singleton info entry to the proposer. + decoded = TypeTwoMultiSignature.decode_bytes(signed_block.proof.data) + proposer_entry = decoded.info[-1] + assert set(proposer_entry.participants.to_validator_indices()) == { + signed_block.block.proposer_index + } async def test_produce_real_attestation_with_valid_signature( self, @@ -1089,17 +1079,12 @@ async def capture_block(block: SignedBlock) -> None: assert len(blocks_produced) == 1 signed_block = blocks_produced[0] - proposer_index = signed_block.block.proposer_index - block_root = hash_tree_root(signed_block.block) - public_key = key_manager[proposer_index].proposal_public - - is_valid = TARGET_SIGNATURE_SCHEME.verify( - pk=public_key, - slot=signed_block.block.slot, - message=block_root, - sig=signed_block.signature.proposer_signature, - ) - assert is_valid + # Confirm the proposer info entry binds the singleton participant set. + decoded = TypeTwoMultiSignature.decode_bytes(signed_block.proof.data) + proposer_entry = decoded.info[-1] + assert set(proposer_entry.participants.to_validator_indices()) == { + signed_block.block.proposer_index + } async def test_block_includes_pending_attestations( self, @@ -1128,10 +1113,10 @@ async def test_block_includes_pending_attestations( public_keys.append(key_manager[vid].attestation_public) xmss_participants = ValidatorIndices(data=participants).to_aggregation_bits() - proof = AggregatedSignatureProof.aggregate( - xmss_participants=xmss_participants, + proof = TypeOneMultiSignature.aggregate( children=[], raw_xmss=list(zip(public_keys, signatures, strict=True)), + xmss_participants=xmss_participants, message=data_root, slot=attestation_data.slot, ) @@ -1161,8 +1146,9 @@ async def capture_block(block: SignedBlock) -> None: body_attestations = signed_block.block.body.attestations assert len(body_attestations) > 0 - attestation_signatures = signed_block.signature.attestation_signatures - assert len(attestation_signatures) == len(body_attestations) + # The merged proof binds one info entry per attestation plus the proposer. + decoded = TypeTwoMultiSignature.decode_bytes(signed_block.proof.data) + assert len(decoded.info) == len(body_attestations) + 1 async def test_multiple_slots_produce_different_attestations( self, diff --git a/tests/lean_spec/subspecs/xmss/test_aggregation.py b/tests/lean_spec/subspecs/xmss/test_aggregation.py index 806a3e296..48f66db08 100644 --- a/tests/lean_spec/subspecs/xmss/test_aggregation.py +++ b/tests/lean_spec/subspecs/xmss/test_aggregation.py @@ -6,8 +6,18 @@ from consensus_testing.keys import XmssKeyManager from lean_spec.subspecs.ssz.hash import hash_tree_root -from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof, AggregationError -from lean_spec.types import ByteListMiB, Checkpoint, Slot, ValidatorIndex, ValidatorIndices +from lean_spec.subspecs.xmss.aggregation import ( + AggregationError, + TypeOneInfo, + TypeOneMultiSignature, +) +from lean_spec.types import ( + ByteListMiB, + Checkpoint, + Slot, + ValidatorIndex, + ValidatorIndices, +) from tests.lean_spec.helpers import make_attestation_data_simple, make_bytes32 @@ -15,7 +25,7 @@ def _sign_and_aggregate( key_manager: XmssKeyManager, validator_ids: list[ValidatorIndex], att_data_args: tuple[Slot, int, int, Checkpoint], -) -> AggregatedSignatureProof: +) -> TypeOneMultiSignature: """Sign attestation data with the given validators and aggregate.""" slot, head, target, source = att_data_args att_data = make_attestation_data_simple(slot, make_bytes32(head), make_bytes32(target), source) @@ -29,30 +39,29 @@ def _sign_and_aggregate( strict=True, ) ) - proof = AggregatedSignatureProof.aggregate( - xmss_participants=xmss_participants, + return TypeOneMultiSignature.aggregate( children=[], raw_xmss=raw_xmss, + xmss_participants=xmss_participants, message=data_root, slot=att_data.slot, ) - return proof def test_aggregate_rejects_empty_inputs() -> None: """Aggregation with no signatures and no children raises an error.""" with pytest.raises(AggregationError, match="At least one raw signature or child proof"): - AggregatedSignatureProof.aggregate( - xmss_participants=None, + TypeOneMultiSignature.aggregate( children=[], raw_xmss=[], + xmss_participants=None, message=make_bytes32(0), slot=Slot(0), ) def test_aggregate_multiple_signatures(key_manager: XmssKeyManager) -> None: - """Multiple validators' signatures can be aggregated into a single proof.""" + """Multiple validators' signatures can be aggregated into a single Type-1 proof.""" source = Checkpoint(root=make_bytes32(10), slot=Slot(0)) att_data = make_attestation_data_simple(Slot(2), make_bytes32(11), make_bytes32(12), source) vids = [ValidatorIndex(i) for i in range(4)] @@ -66,22 +75,18 @@ def test_aggregate_multiple_signatures(key_manager: XmssKeyManager) -> None: ) ) - proof = AggregatedSignatureProof.aggregate( - xmss_participants=xmss_participants, + proof = TypeOneMultiSignature.aggregate( children=[], raw_xmss=raw_xmss, + xmss_participants=xmss_participants, message=hash_tree_root(att_data), slot=att_data.slot, ) - assert set(proof.participants.to_validator_indices()) == set(vids) + assert set(proof.info.participants.to_validator_indices()) == set(vids) public_keys = [key_manager[vid].attestation_public for vid in vids] - proof.verify( - public_keys=public_keys, - message=hash_tree_root(att_data), - slot=att_data.slot, - ) + proof.verify(public_keys=public_keys, message=hash_tree_root(att_data), slot=att_data.slot) def test_aggregate_children_with_raw_signatures(key_manager: XmssKeyManager) -> None: @@ -106,23 +111,19 @@ def test_aggregate_children_with_raw_signatures(key_manager: XmssKeyManager) -> ) ) - parent = AggregatedSignatureProof.aggregate( - xmss_participants=xmss_participants, + parent = TypeOneMultiSignature.aggregate( children=[(child, [key_manager[ValidatorIndex(i)].attestation_public for i in range(2)])], raw_xmss=raw_xmss, + xmss_participants=xmss_participants, message=hash_tree_root(att_data), slot=att_data.slot, ) expected_vids = {ValidatorIndex(i) for i in range(4)} - assert set(parent.participants.to_validator_indices()) == expected_vids + assert set(parent.info.participants.to_validator_indices()) == expected_vids public_keys = [key_manager[ValidatorIndex(i)].attestation_public for i in range(4)] - parent.verify( - public_keys=public_keys, - message=hash_tree_root(att_data), - slot=att_data.slot, - ) + parent.verify(public_keys=public_keys, message=hash_tree_root(att_data), slot=att_data.slot) def test_aggregate_three_children(key_manager: XmssKeyManager) -> None: @@ -141,23 +142,19 @@ def test_aggregate_three_children(key_manager: XmssKeyManager) -> None: child_b_pks = [key_manager[ValidatorIndex(1)].attestation_public] child_c_pks = [key_manager[ValidatorIndex(2)].attestation_public] - parent = AggregatedSignatureProof.aggregate( - xmss_participants=None, + parent = TypeOneMultiSignature.aggregate( children=[(child_a, child_a_pks), (child_b, child_b_pks), (child_c, child_c_pks)], raw_xmss=[], + xmss_participants=None, message=hash_tree_root(att_data), slot=att_data.slot, ) expected_vids = {ValidatorIndex(i) for i in range(3)} - assert set(parent.participants.to_validator_indices()) == expected_vids + assert set(parent.info.participants.to_validator_indices()) == expected_vids public_keys = [key_manager[ValidatorIndex(i)].attestation_public for i in range(3)] - parent.verify( - public_keys=public_keys, - message=hash_tree_root(att_data), - slot=att_data.slot, - ) + parent.verify(public_keys=public_keys, message=hash_tree_root(att_data), slot=att_data.slot) def test_aggregate_children_of_children(key_manager: XmssKeyManager) -> None: @@ -169,7 +166,7 @@ def test_aggregate_children_of_children(key_manager: XmssKeyManager) -> None: ) msg = hash_tree_root(att_data) - # Level 0: four individual leaf proofs + # Level 0: four individual leaf proofs. leaf_a = _sign_and_aggregate(key_manager, [ValidatorIndex(0)], att_args) leaf_b = _sign_and_aggregate(key_manager, [ValidatorIndex(1)], att_args) leaf_c = _sign_and_aggregate(key_manager, [ValidatorIndex(2)], att_args) @@ -180,32 +177,34 @@ def test_aggregate_children_of_children(key_manager: XmssKeyManager) -> None: leaf_c_pks = [key_manager[ValidatorIndex(2)].attestation_public] leaf_d_pks = [key_manager[ValidatorIndex(3)].attestation_public] - # Level 1: two intermediate proofs - mid_ab = AggregatedSignatureProof.aggregate( - xmss_participants=None, + # Level 1: two intermediate proofs. + mid_ab = TypeOneMultiSignature.aggregate( children=[(leaf_a, leaf_a_pks), (leaf_b, leaf_b_pks)], raw_xmss=[], + xmss_participants=None, message=msg, slot=att_data.slot, ) - mid_cd = AggregatedSignatureProof.aggregate( - xmss_participants=None, + mid_cd = TypeOneMultiSignature.aggregate( children=[(leaf_c, leaf_c_pks), (leaf_d, leaf_d_pks)], raw_xmss=[], + xmss_participants=None, message=msg, slot=att_data.slot, ) - # Level 2: final root proof - root = AggregatedSignatureProof.aggregate( - xmss_participants=None, + # Level 2: final root proof. + root = TypeOneMultiSignature.aggregate( children=[(mid_ab, leaf_a_pks + leaf_b_pks), (mid_cd, leaf_c_pks + leaf_d_pks)], raw_xmss=[], + xmss_participants=None, message=msg, slot=att_data.slot, ) - assert set(root.participants.to_validator_indices()) == {ValidatorIndex(i) for i in range(4)} + assert set(root.info.participants.to_validator_indices()) == { + ValidatorIndex(i) for i in range(4) + } root.verify( public_keys=[key_manager[ValidatorIndex(i)].attestation_public for i in range(4)], message=msg, @@ -222,14 +221,14 @@ def test_aggregate_mixed_children_and_raw_multiple(key_manager: XmssKeyManager) ) msg = hash_tree_root(att_data) - # Two child proofs + # Two child proofs. child_a = _sign_and_aggregate(key_manager, [ValidatorIndex(0)], att_args) child_b = _sign_and_aggregate(key_manager, [ValidatorIndex(1)], att_args) child_a_pks = [key_manager[ValidatorIndex(0)].attestation_public] child_b_pks = [key_manager[ValidatorIndex(1)].attestation_public] - # Additional raw signatures from validators 2 and 3 + # Additional raw signatures from validators 2 and 3. extra_vids = [ValidatorIndex(2), ValidatorIndex(3)] xmss_participants = ValidatorIndices(data=extra_vids).to_aggregation_bits() raw_xmss = list( @@ -240,15 +239,17 @@ def test_aggregate_mixed_children_and_raw_multiple(key_manager: XmssKeyManager) ) ) - proof = AggregatedSignatureProof.aggregate( - xmss_participants=xmss_participants, + proof = TypeOneMultiSignature.aggregate( children=[(child_a, child_a_pks), (child_b, child_b_pks)], raw_xmss=raw_xmss, + xmss_participants=xmss_participants, message=msg, slot=att_data.slot, ) - assert set(proof.participants.to_validator_indices()) == {ValidatorIndex(i) for i in range(4)} + assert set(proof.info.participants.to_validator_indices()) == { + ValidatorIndex(i) for i in range(4) + } proof.verify( public_keys=[key_manager[ValidatorIndex(i)].attestation_public for i in range(4)], message=msg, @@ -257,57 +258,28 @@ def test_aggregate_mixed_children_and_raw_multiple(key_manager: XmssKeyManager) def test_aggregate_wrong_message_fails_verification(key_manager: XmssKeyManager) -> None: - """Verification fails when the message doesn't match what was signed.""" + """Verification fails when the caller passes a message that does not match the proof.""" source = Checkpoint(root=make_bytes32(120), slot=Slot(0)) att_data = make_attestation_data_simple(Slot(1), make_bytes32(121), make_bytes32(122), source) vid = ValidatorIndex(0) - xmss_participants = ValidatorIndices(data=[vid]).to_aggregation_bits() - raw_xmss = [ - ( - key_manager[vid].attestation_public, - key_manager.sign_attestation_data(vid, att_data), - ) - ] - - proof = AggregatedSignatureProof.aggregate( - xmss_participants=xmss_participants, - children=[], - raw_xmss=raw_xmss, - message=hash_tree_root(att_data), - slot=att_data.slot, - ) + proof = _sign_and_aggregate(key_manager, [vid], (att_data.slot, 121, 122, source)) - wrong_message = make_bytes32(999) with pytest.raises(AggregationError, match="verification failed"): proof.verify( public_keys=[key_manager[vid].attestation_public], - message=wrong_message, + message=make_bytes32(999), slot=att_data.slot, ) def test_aggregate_wrong_slot_fails_verification(key_manager: XmssKeyManager) -> None: - """Verification fails when the slot doesn't match what was signed.""" + """Verification fails when the caller passes a slot that does not match the proof.""" source = Checkpoint(root=make_bytes32(130), slot=Slot(0)) att_data = make_attestation_data_simple(Slot(2), make_bytes32(131), make_bytes32(132), source) vid = ValidatorIndex(1) - xmss_participants = ValidatorIndices(data=[vid]).to_aggregation_bits() - raw_xmss = [ - ( - key_manager[vid].attestation_public, - key_manager.sign_attestation_data(vid, att_data), - ) - ] - - proof = AggregatedSignatureProof.aggregate( - xmss_participants=xmss_participants, - children=[], - raw_xmss=raw_xmss, - message=hash_tree_root(att_data), - slot=att_data.slot, - ) + proof = _sign_and_aggregate(key_manager, [vid], (att_data.slot, 131, 132, source)) with pytest.raises(AggregationError, match="verification failed"): proof.verify( @@ -323,23 +295,19 @@ def test_aggregate_corrupted_proof_fails_verification(key_manager: XmssKeyManage att_data = make_attestation_data_simple(Slot(3), make_bytes32(141), make_bytes32(142), source) vid = ValidatorIndex(2) - proof = _sign_and_aggregate( - key_manager, - [vid], - (att_data.slot, 141, 142, source), - ) + proof = _sign_and_aggregate(key_manager, [vid], (att_data.slot, 141, 142, source)) - # Corrupt the proof data by flipping bytes - corrupted_data = bytearray(proof.proof_data.encode_bytes()) - corrupted_data[10] ^= 0xFF - corrupted_data[20] ^= 0xFF - corrupted_proof = AggregatedSignatureProof( - participants=proof.participants, - proof_data=ByteListMiB(data=bytes(corrupted_data)), + corrupted_bytes = bytearray(proof.proof.data) + corrupted_bytes[10] ^= 0xFF + corrupted_bytes[20] ^= 0xFF + corrupted_blob = ByteListMiB(data=bytes(corrupted_bytes)) + corrupted = TypeOneMultiSignature( + info=proof.info.model_copy(update={"proof": corrupted_blob}), + proof=corrupted_blob, ) with pytest.raises(AggregationError, match="verification failed"): - corrupted_proof.verify( + corrupted.verify( public_keys=[key_manager[vid].attestation_public], message=hash_tree_root(att_data), slot=att_data.slot, @@ -347,7 +315,7 @@ def test_aggregate_corrupted_proof_fails_verification(key_manager: XmssKeyManage def test_aggregate_child_signed_different_message_fails(key_manager: XmssKeyManager) -> None: - """Aggregating children that signed different messages fails.""" + """Aggregating children that signed different messages fails inside the binding.""" source = Checkpoint(root=make_bytes32(150), slot=Slot(0)) att_args_a = (Slot(4), 151, 152, source) att_args_b = (Slot(4), 161, 162, source) @@ -355,20 +323,18 @@ def test_aggregate_child_signed_different_message_fails(key_manager: XmssKeyMana att_args_b[0], make_bytes32(att_args_b[1]), make_bytes32(att_args_b[2]), att_args_b[3] ) - # Child A signs message A child_a = _sign_and_aggregate(key_manager, [ValidatorIndex(0)], att_args_a) - # Child B signs message B (different) child_b = _sign_and_aggregate(key_manager, [ValidatorIndex(1)], att_args_b) child_a_pks = [key_manager[ValidatorIndex(0)].attestation_public] child_b_pks = [key_manager[ValidatorIndex(1)].attestation_public] - # Aggregation rejects children that signed different messages + # The binding rejects mismatching messages during recursive aggregation. with pytest.raises(AggregationError): - AggregatedSignatureProof.aggregate( - xmss_participants=None, + TypeOneMultiSignature.aggregate( children=[(child_a, child_a_pks), (child_b, child_b_pks)], raw_xmss=[], + xmss_participants=None, message=hash_tree_root(att_data_b), slot=att_data_b.slot, ) @@ -376,19 +342,22 @@ def test_aggregate_child_signed_different_message_fails(key_manager: XmssKeyMana def test_aggregate_rejects_single_child_without_raw(key_manager: XmssKeyManager) -> None: """A single child without raw signatures is rejected (need at least two children).""" - # Create a stub child proof without calling the Rust bindings - stub_child = AggregatedSignatureProof( - participants=ValidatorIndices(data=[ValidatorIndex(0)]).to_aggregation_bits(), - proof_data=ByteListMiB(data=b"\x00"), + placeholder = ByteListMiB(data=b"\x00") + stub_child = TypeOneMultiSignature( + info=TypeOneInfo( + participants=ValidatorIndices(data=[ValidatorIndex(0)]).to_aggregation_bits(), + proof=placeholder, + ), + proof=placeholder, ) with pytest.raises(AggregationError, match="At least two child proofs"): - AggregatedSignatureProof.aggregate( - xmss_participants=None, + TypeOneMultiSignature.aggregate( children=[ (stub_child, [key_manager[ValidatorIndex(i)].attestation_public for i in range(1)]) ], raw_xmss=[], + xmss_participants=None, message=make_bytes32(0), slot=Slot(0), ) @@ -401,7 +370,7 @@ def test_aggregate_rejects_mismatched_participant_count( source = Checkpoint(root=make_bytes32(60), slot=Slot(0)) att_data = make_attestation_data_simple(Slot(7), make_bytes32(61), make_bytes32(62), source) - # Claim 2 participants but only provide 1 signature + # Claim 2 participants but only provide 1 signature. xmss_participants = ValidatorIndices( data=[ValidatorIndex(0), ValidatorIndex(1)] ).to_aggregation_bits() @@ -413,10 +382,10 @@ def test_aggregate_rejects_mismatched_participant_count( ] with pytest.raises(AggregationError, match="does not match"): - AggregatedSignatureProof.aggregate( - xmss_participants=xmss_participants, + TypeOneMultiSignature.aggregate( children=[], raw_xmss=raw_xmss, + xmss_participants=xmss_participants, message=hash_tree_root(att_data), slot=att_data.slot, ) diff --git a/tests/lean_spec/types/test_union.py b/tests/lean_spec/types/test_union.py index f71f9c96d..5064dec80 100644 --- a/tests/lean_spec/types/test_union.py +++ b/tests/lean_spec/types/test_union.py @@ -1,8 +1,7 @@ from __future__ import annotations import io -from typing import Type as PyType -from typing import cast +from typing import Type as PyType, cast import pytest from pydantic import ValidationError, create_model diff --git a/uv.lock b/uv.lock index f1a2f9fff..8e694dd37 100644 --- a/uv.lock +++ b/uv.lock @@ -866,8 +866,8 @@ requires-dist = [ [[package]] name = "lean-multisig-py" -version = "0.1.0" -source = { git = "https://github.com/anshalshukla/leanMultisig-py?branch=devnet4#6a0a8b03aa9c467af91b9e5443e283ef45158373" } +version = "0.0.3" +source = { git = "https://github.com/anshalshukla/leanMultisig-py?tag=v0.0.4#9f5194f3336a814b4484b9f29020eaeb36f038da" } [[package]] name = "lean-spec" @@ -940,7 +940,7 @@ requires-dist = [ { name = "aioquic", specifier = ">=1.2.0,<2" }, { name = "cryptography", specifier = ">=46.0.0" }, { name = "httpx", specifier = ">=0.28.0,<1" }, - { name = "lean-multisig-py", git = "https://github.com/anshalshukla/leanMultisig-py?branch=devnet4" }, + { name = "lean-multisig-py", git = "https://github.com/anshalshukla/leanMultisig-py?tag=v0.0.4" }, { name = "numba", specifier = ">=0.61.0,<1" }, { name = "numpy", specifier = ">=2.0.0,<3" }, { name = "prometheus-client", specifier = ">=0.21.0,<1" }, @@ -957,7 +957,7 @@ dev = [ { name = "ipdb", specifier = ">=0.13" }, { name = "ipython", specifier = ">=8.31.0,<9" }, { name = "lean-ethereum-testing", editable = "packages/testing" }, - { name = "lean-multisig-py", git = "https://github.com/anshalshukla/leanMultisig-py?branch=devnet4" }, + { name = "lean-multisig-py", git = "https://github.com/anshalshukla/leanMultisig-py?tag=v0.0.4" }, { name = "mdformat", specifier = "==0.7.22" }, { name = "mkdocs", specifier = ">=1.6.1,<2" }, { name = "mkdocs-material", specifier = ">=9.5.45,<10" }, @@ -987,7 +987,7 @@ lint = [ test = [ { name = "hypothesis", specifier = ">=6.138.14" }, { name = "lean-ethereum-testing", editable = "packages/testing" }, - { name = "lean-multisig-py", git = "https://github.com/anshalshukla/leanMultisig-py?branch=devnet4" }, + { name = "lean-multisig-py", git = "https://github.com/anshalshukla/leanMultisig-py?tag=v0.0.4" }, { name = "pycryptodome", specifier = ">=3.20.0,<4" }, { name = "pytest", specifier = ">=8.3.3,<9" }, { name = "pytest-asyncio", specifier = ">=1.0.0" },