diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4bbdba5c..1f4f84cda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,9 +76,15 @@ jobs: - name: Run tests run: just test + # coverage-gate, fill-tests and interop-tests run on macOS because the + # lean_multisig_py prover's process-global setup is corrupted by xdist + # parallelism; these jobs must run serially and macOS runners were the + # stable choice during bring-up. This is an upstream limitation, not a + # preference: revisit (and prefer ubuntu for cost) once the prover setup + # is per-process safe. See the matching note in the Justfile fill-ci. coverage-gate: name: Coverage gate - Python 3.12 - runs-on: ubuntu-latest + runs-on: macos-latest steps: - name: Checkout leanSpec uses: actions/checkout@v4 @@ -104,7 +110,7 @@ jobs: fill-tests: name: Fill test fixtures - Python 3.14 - runs-on: ubuntu-latest + runs-on: macos-latest steps: - name: Checkout leanSpec uses: actions/checkout@v4 @@ -130,7 +136,7 @@ jobs: interop-tests: name: Interop tests - Multi-node consensus - runs-on: ubuntu-latest + runs-on: macos-latest timeout-minutes: 10 steps: - name: Checkout leanSpec diff --git a/Justfile b/Justfile index 88d20a35d..a6faa1828 100644 --- a/Justfile +++ b/Justfile @@ -81,9 +81,13 @@ test-consensus *args: uv run --group test pytest -n auto --maxprocesses=10 --durations=10 --dist=worksteal tests/lean_spec/subspecs/containers tests/lean_spec/subspecs/forkchoice tests/lean_spec/subspecs/networking "$@" # Canonical CI fixture run; contributors should use `uv run fill` directly +# Runs serially (no -n auto): xdist workers race on the lean_multisig_py +# prover's process-global setup, which corrupts proofs intermittently. +# This is an upstream limitation; restore -n auto once the prover setup +# is per-process safe. See the matching note in .github/workflows/ci.yml. [group('tests'), private] fill-ci *args: - uv run --group test fill --fork=Lstar --clean -n auto "$@" + uv run --group test fill --fork=Lstar --clean "$@" # Run API conformance tests against an external client [group('tests')] diff --git a/packages/testing/src/consensus_testing/keys.py b/packages/testing/src/consensus_testing/keys.py index 76977dbc9..4c0d6093b 100755 --- a/packages/testing/src/consensus_testing/keys.py +++ b/packages/testing/src/consensus_testing/keys.py @@ -42,13 +42,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 TypeOneMultiSignature from lean_spec.subspecs.xmss.constants import TARGET_CONFIG from lean_spec.subspecs.xmss.containers import ( PublicKey, @@ -67,7 +64,13 @@ HashTreeOpening, Randomness, ) -from lean_spec.types import Bytes32, Slot, Uint64, ValidatorIndex, ValidatorIndices +from lean_spec.types import ( + Bytes32, + Slot, + Uint64, + ValidatorIndex, + ValidatorIndices, +) KeyRole = Literal["attestation", "proposal"] """Discriminator for which signing role's key to load from a validator key pair.""" @@ -514,18 +517,21 @@ 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. Returns: - Aggregated signature proof combining all validators' signatures. + Cryptographically valid Type-1 proof covering validator_ids. """ raw_xmss = [ ( @@ -534,34 +540,32 @@ 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( + xmss_participants=ValidatorIndices(data=validator_ids).to_aggregation_bits(), children=[], raw_xmss=raw_xmss, 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 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. Args: aggregated_attestations: Attestations with aggregation bitfields set. @@ -569,11 +573,11 @@ 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() @@ -582,7 +586,7 @@ def build_attestation_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. + # Collect the attestation public keys for each participant. public_keys = [self.get_public_keys(vid)[0] for vid in validator_ids] # Gather individual signatures, computing any that are missing. @@ -593,16 +597,17 @@ def build_attestation_signatures( # 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..386203e98 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py +++ b/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py @@ -6,17 +6,20 @@ 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.attestation import AggregatedAttestation, AttestationData +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.types import ( + AggregationBits, + Boolean, + ByteList512KiB, + Bytes32, + Checkpoint, + Slot, + ValidatorIndex, +) from ..keys import XmssKeyManager from ..test_types import BlockSpec @@ -60,10 +63,6 @@ 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": "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- @@ -72,6 +71,15 @@ class VerifySignaturesTest(BaseConsensusFixture): first body attestation with one whose aggregation_bits carry no set bit. Exercises the empty-participants check inside signature verification. + - `{"operation": "corrupt_proof"}`: Replace the merged proof with + a short non-decodable blob. Exercises the Type-2 decode check. + - `{"operation": "append_phantom_attestation"}`: Add a body + attestation with no matching proof component. Exercises the + component count check between the body and the merged proof. + - `{"operation": "mutate_state_root"}`: Change a block field after + signing so the block root differs. Exercises the per-component + message binding that prevents reusing an honest proof under a + different message. Tampered blocks bypass the builder's structural invariants. The resulting fixture pins the exact rejection a client must raise when @@ -153,16 +161,6 @@ def _apply_tamper(self, signed_block: SignedBlock) -> SignedBlock: assert self.tamper is not None 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} - ) - return signed_block.model_copy(update={"signature": tampered_signatures}) - if operation == "set_proposer_index": value = self.tamper.get("value") if value is None: @@ -185,4 +183,43 @@ def _apply_tamper(self, signed_block: SignedBlock) -> SignedBlock: new_block = signed_block.block.model_copy(update={"body": new_body}) return signed_block.model_copy(update={"block": new_block}) + if operation == "corrupt_proof": + # Replace the merged proof with a short non-decodable blob. + # Decoding the Type-2 envelope must fail before verification. + return signed_block.model_copy( + update={"proof": ByteList512KiB(data=b"\x00\x01\x02\x03")} + ) + + if operation == "append_phantom_attestation": + # Add a body attestation with no matching proof component. + # The proof binds one component per original attestation plus + # the proposer, so the body now claims more components than the + # proof carries. + body = signed_block.block.body + phantom_data = AttestationData( + slot=Slot(0), + head=Checkpoint(root=Bytes32(b"\x00" * 32), slot=Slot(0)), + target=Checkpoint(root=Bytes32(b"\x00" * 32), slot=Slot(0)), + source=Checkpoint(root=Bytes32(b"\x00" * 32), slot=Slot(0)), + ) + phantom = AggregatedAttestation( + aggregation_bits=AggregationBits(data=[Boolean(True)]), + data=phantom_data, + ) + new_attestations = AggregatedAttestations(data=[*body.attestations.data, phantom]) + new_body = body.model_copy(update={"attestations": new_attestations}) + new_block = signed_block.block.model_copy(update={"body": new_body}) + return signed_block.model_copy(update={"block": new_block}) + + if operation == "mutate_state_root": + # Change a block field after signing so the block root differs. + # The proposer component's bound message no longer matches the + # recomputed block root, even though the signature is honest. + # This is the repackaging vector: an honest proof reused under + # a different message. + tampered_block = signed_block.block.model_copy( + update={"state_root": Bytes32(b"\xff" * 32)} + ) + return signed_block.model_copy(update={"block": tampered_block}) + raise ValueError(f"Unknown tamper operation: {operation!r}") 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..9f17eea0b 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,9 +6,9 @@ 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 TypeOneMultiSignature from lean_spec.types import ( - ByteListMiB, + ByteList512KiB, Bytes32, CamelModel, Checkpoint, @@ -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,24 +190,20 @@ 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 = ByteList512KiB(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(participants=aggregation_bits, 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( + participants=aggregation_bits, 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(participants=aggregation_bits, proof=placeholder) # Append invalid attestation to the block body. updated_block = block.model_copy( 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..f95b2f459 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,26 @@ 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.ssz.hash import hash_tree_root -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, CamelModel, Slot, ValidatorIndex, ValidatorIndices +from lean_spec.types import ( + ByteList512KiB, + Bytes32, + CamelModel, + Slot, + ValidatorIndex, + ValidatorIndices, +) from ..keys import XmssKeyManager, create_dummy_signature from .aggregated_attestation_spec import AggregatedAttestationSpec @@ -241,37 +243,86 @@ 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: + 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.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 = ByteList512KiB(data=b"") + envelope = TypeTwoMultiSignature(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=ByteList512KiB(data=proof_bytes), ) def build_signed_block( @@ -353,13 +404,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 +430,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 +440,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 +464,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 +480,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 +520,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 +536,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 +575,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..81e58cd8b 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,9 +6,9 @@ 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 TypeOneMultiSignature from lean_spec.types import ( - ByteListMiB, + ByteList512KiB, Bytes32, CamelModel, Checkpoint, @@ -188,9 +188,10 @@ def build_signed( # Correct participant bitfield but zeroed-out proof bytes. # Exercises signature verification rejection. if not self.valid_signature: - proof = AggregatedSignatureProof( + placeholder = ByteList512KiB(data=b"\x00" * 32) + proof = TypeOneMultiSignature( participants=ValidatorIndices(data=validator_ids).to_aggregation_bits(), - proof_data=ByteListMiB(data=b"\x00" * 32), + proof=placeholder, ) return SignedAggregatedAttestation(data=attestation_data, proof=proof) @@ -205,9 +206,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( + proof = TypeOneMultiSignature( participants=ValidatorIndices(data=validator_ids).to_aggregation_bits(), - proof_data=proof.proof_data, + proof=proof.proof, ) return SignedAggregatedAttestation(data=attestation_data, proof=proof) diff --git a/pyproject.toml b/pyproject.toml index 208630ff1..e3bfe06ff 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.5", "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"] @@ -115,7 +116,7 @@ markers = [ ] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" -timeout = 300 +timeout = 600 [tool.coverage.run] source = ["src"] @@ -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.5" } [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.5", "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..f0f61945b 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 ByteList512KiB, 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: ByteList512KiB + """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 8739e9efc..a65e0a59a 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, Sequence -from collections.abc import Set as AbstractSet +from collections.abc import Iterable, Sequence, Set as AbstractSet from typing import Any, ClassVar from lean_spec.forks.lstar.containers import ( @@ -38,7 +37,12 @@ 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, + 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, @@ -649,8 +653,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. @@ -662,7 +666,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 @@ -753,7 +757,7 @@ def build_block( found_entries = True - selected, _ = AggregatedSignatureProof.select_greedily(proofs) + selected, _ = TypeOneMultiSignature.select_greedily(proofs) aggregated_signatures.extend(selected) for proof in selected: aggregated_attestations.append( @@ -802,7 +806,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) @@ -825,7 +829,7 @@ def build_block( ) for proof in proofs ] - sig = AggregatedSignatureProof.aggregate( + sig = TypeOneMultiSignature.aggregate( xmss_participants=None, children=children, raw_xmss=[], @@ -862,88 +866,79 @@ def verify_signatures( validators: Validators, ) -> bool: """ - Verify all XMSS signatures in this signed block. - - Checks that: - - - Each body attestation is signed by participating validators - - The proposer signed the block root with the proposal key + Verify the merged Type-2 proof carried by a signed block. + The block envelope holds one SSZ-encoded Type-2 proof binding + every body attestation plus 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 - # 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() + num_validators = Uint64(len(validators)) + public_keys_per_message: list[list[PublicKey]] = [] - # 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) + # Each component is bound to the message and slot it signed. + # + # Without this binding a proposer could pair honest signatures + # with attacker-chosen attestation data that resolves to the same + # pubkeys, crediting validators for votes they never cast. + message_bindings: list[tuple[Bytes32, Slot]] = [] + # One pubkey set per attestation, in body order. + # + # The attestation list and the proof component list are parallel. + # Each attestation names the validators that voted for its data. + # Its matching proof component proves those validators signed. + for aggregated_attestation in 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] - - try: - aggregated_signature.verify( - public_keys=public_keys, - message=attestation_data_root, - slot=aggregated_attestation.data.slot, + public_keys_per_message.append( + [validators[vid].get_attestation_pubkey() for vid in validator_ids] + ) + message_bindings.append( + ( + hash_tree_root(aggregated_attestation.data), + aggregated_attestation.data.slot, ) - except AggregationError as exc: - raise AssertionError( - f"Attestation aggregated signature verification failed: {exc}" - ) from exc + ) - # Verify the proposer's signature over the block root. + # Final component: the proposer's signature over the block root. # - # The proposer signs hash_tree_root(block) with their proposal key. + # The proposer signs the block root with their proposal key. # This proves the proposer endorsed this specific block. + # It is a single-participant entry, distinct from the vote entries. 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 = validators[proposer_index] - block_root = hash_tree_root(block) + public_keys_per_message.append([validators[proposer_index].get_proposal_pubkey()]) + message_bindings.append((hash_tree_root(block), block.slot)) try: - valid = self.sig_scheme.verify( - proposer.get_proposal_pubkey(), - block.slot, - block_root, - signatures.proposer_signature, + type_two.verify( + public_keys_per_message=public_keys_per_message, + messages=message_bindings, ) - except (ValueError, IndexError): - valid = False - assert valid, "Proposer block signature verification failed" + except AggregationError as exc: + raise AssertionError(f"Block proof verification failed: {exc}") from exc return True @@ -1213,7 +1208,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, @@ -1279,6 +1274,19 @@ def on_block( f"Sync parent chain before processing block at slot {block.slot}." ) + # The block body constrains how many distinct AttestationData + # entries it may carry. + aggregated_attestations = block.body.attestations + 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 len(att_data_set) <= int(MAX_ATTESTATIONS_DATA), ( + f"Block contains {len(att_data_set)} distinct AttestationData entries; " + f"maximum is {MAX_ATTESTATIONS_DATA}" + ) + # Validate cryptographic signatures valid_signatures = self.verify_signatures(signed_block, parent_state.validators) @@ -1305,41 +1313,31 @@ def on_block( } ) - # Process block body attestations and their signatures - # Block attestations go directly to "known" payloads - 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, ( - 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]] = { + # Register each block attestation's data in the known pool. + # + # Only the data key is recorded here, with an empty proof set. + # The block carries one merged proof for all attestations. + # That proof is verified as a whole and not decomposed at import. + # Per-attestation proofs reach the pools through the + # deconstruction and gossip path instead. + # + # Consequence: a block's own attestations contribute zero weight + # to the head computation triggered by this import. + # Recovered Type-1 proofs land in the new pool and migrate to + # the known pool at the next acceptance tick. + # Head weight from block-imported votes is therefore deferred + # by up to one slot. + # Shallow-copy the dict and its inner sets to preserve immutability. + block_proofs: dict[AttestationData, set[TypeOneMultiSignature]] = { k: set(v) for k, v in store.latest_known_aggregated_payloads.items() } - for att, proof in zip(aggregated_attestations, attestation_signatures, strict=True): - block_proofs.setdefault(att.data, set()).add(proof) + for aggregated_attestation in aggregated_attestations: + block_proofs.setdefault(aggregated_attestation.data, set()) - # 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 based on new block and attestations. store = self.update_head(store) # Prune stale attestation data when finalization advances @@ -1351,7 +1349,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. @@ -1655,7 +1653,8 @@ 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( + + child_proofs, covered = TypeOneMultiSignature.select_greedily( new.get(data), known.get(data) ) @@ -1676,18 +1675,23 @@ 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 # @@ -1709,7 +1713,7 @@ def aggregate(self, store: LstarStore) -> tuple[LstarStore, list[SignedAggregate # Hand everything to the XMSS subspec. # Out comes a single proof covering all selected validators. - proof = AggregatedSignatureProof.aggregate( + proof = TypeOneMultiSignature.aggregate( xmss_participants=xmss_participants, children=children, raw_xmss=raw_xmss, @@ -1722,7 +1726,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) @@ -1880,8 +1884,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 @@ -1892,6 +1896,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..79321c0da 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. + Block import does not feed individual proofs into this map directly. + The block-level proof is a merged Type-2 blob verified as a whole. + On gossip-block import, any validator deconstructs that Type-2 into + per-message proofs, writes them back here, and gossips the aggregate. """ - 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 7b1a0bd82..cf92d010b 100644 --- a/src/lean_spec/forks/protocol.py +++ b/src/lean_spec/forks/protocol.py @@ -93,9 +93,10 @@ class SpecAggregatedAttestationsType(SpecSSZType, Protocol): 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 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..c738e0c67 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(8) """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 5f1399582..d0f3ae7d7 100644 --- a/src/lean_spec/subspecs/sync/service.py +++ b/src/lean_spec/subspecs/sync/service.py @@ -11,6 +11,7 @@ from dataclasses import dataclass, field from lean_spec.forks import ( + AttestationData, Block, LstarSpec, SignedAggregatedAttestation, @@ -24,7 +25,14 @@ from lean_spec.subspecs.networking.transport.peer_id import PeerId from lean_spec.subspecs.ssz.hash import hash_tree_root from lean_spec.subspecs.storage import Database +from lean_spec.subspecs.xmss.aggregation import ( + AggregationError, + TypeOneMultiSignature, + TypeTwoMultiSignature, +) +from lean_spec.subspecs.xmss.containers import PublicKey from lean_spec.types import Bytes32, Slot, SubnetId +from lean_spec.types.exceptions import SSZError from .backfill_sync import BackfillSync, NetworkRequester from .block_cache import BlockCache @@ -150,6 +158,15 @@ class SyncService: Same buffering strategy as individual attestations. """ + _pending_block_aggregates: list[SignedAggregatedAttestation] = field(default_factory=list) + """Combined aggregates recovered from processed blocks. + + Every processed block is deconstructed in the block wrapper, which + queues its combined aggregates when this node is in the aggregator + role. The gossip umbrella drains and publishes them after the store + is updated. + """ + def __post_init__(self) -> None: """Bind the default block processor and wire sub-components.""" # Tests can pass an explicit processor and skip this path. @@ -254,6 +271,19 @@ def _process_block_wrapper( # We only count blocks that pass validation and update the store. self._blocks_processed += 1 + # Deconstruct every processed block, regardless of how it arrived. + # + # Gossip, head-sync, and backfilled descendants all funnel through + # here. Recovering the per-attestation proofs from the merged block + # proof and writing them into the pool is what gives a catching-up + # node block-imported attestation weight in fork choice. + # Non-aggregators do the work for their local pool only and never + # republish, so the gossip queue is only fed when this node is in + # the aggregator role. + new_store, aggregates = self._deconstruct_block_into_store(new_store, block) + if self.is_aggregator: + self._pending_block_aggregates.extend(aggregates) + # Write-through persistence: synchronous and optional. if self.database is not None: self._persist_block(new_store, block.block) @@ -418,6 +448,7 @@ async def on_gossip_block( self.store = new_store # A new block may unlock attestations buffered earlier; retry them. self._replay_pending_attestations() + await self._publish_pending_block_aggregates() # Gossip may deliver the final block needed to reach finalized. await self._check_sync_complete() @@ -560,6 +591,182 @@ def _replay_pending_attestations(self) -> None: except (AssertionError, KeyError): self._pending_aggregated_attestations.append(signed_attestation) + def _deconstruct_block_into_store( + self, + store: Store, + block: SignedBlock, + ) -> tuple[Store, list[SignedAggregatedAttestation]]: + """Recover per-attestation proofs from a processed block. + + On block import we already trust the block-attestation participant + bitfields via spec on_block signature verification. The block carries + one merged Type-2 proof binding every attestation in its body. + + For each block attestation that covers validators not already held + in the given store: + + 1. Extract that data's Type-1 proof out of the block's Type-2 proof. + 2. Merge it with all local partial Type-1 proofs for the same data + into one Type-1 proof whose participant bits are the union. + 3. Write the combined proof into the pending pool. + + If the data was never seen locally, the extracted Type-1 is used + as-is. + + Runs for every node, including non-validators, so the per-attestation + proofs reach the local pool and contribute fork-choice weight after + the next acceptance tick. Publishing is left to the caller and only + the aggregator role should drain the returned list onto gossip. + + Returns: + The store (possibly unchanged if no recovery was needed) and + the combined aggregates produced by this call. + """ + block_attestations = list(block.block.body.attestations) + if not block_attestations: + return store, [] + + # The Type-2 proof was built against the parent state's validator set. + # Without it we cannot resolve the pubkey layout the proof was bound to. + parent_state = store.states.get(block.block.parent_root) + if parent_state is None: + return store, [] + validators = parent_state.validators + + # The wrapper must not raise on a malformed proof. + # The block already passed signature verification upstream, so this + # catches the realistic SSZ deserialization failure modes only. + try: + type_two = TypeTwoMultiSignature.decode_bytes(block.proof.data) + except (SSZError, ValueError, IndexError) as exc: + logger.debug("Post-block Type-2 decode failed: %s", exc) + return store, [] + + # Build the per-message pubkey layout once. + # The layout is invariant per block: one entry per body attestation + # in order, then the proposer entry. Hoisted out of the per-att loop + # to avoid quadratic work when many block attestations need splitting. + public_keys_per_message: list[list[PublicKey]] = [] + for att in block_attestations: + public_keys_per_message.append( + [ + validators[vid].get_attestation_pubkey() + for vid in att.aggregation_bits.to_validator_indices() + ] + ) + public_keys_per_message.append( + [validators[block.block.proposer_index].get_proposal_pubkey()] + ) + + # Index local partial Type-1 proofs by AttestationData root. Equivalent + # AttestationData instances from different code paths may not share a + # dict key, so match on the hash tree root instead. + local_proofs_by_root: dict[Bytes32, list[TypeOneMultiSignature]] = {} + for data, proofs in store.latest_new_aggregated_payloads.items(): + local_proofs_by_root.setdefault(hash_tree_root(data), []).extend(proofs) + + # Working copy of the pending pool. + # The combined proof is retained locally so the block-sourced + # aggregate survives without depending on gossip loopback. Shallow + # copy the dict and its inner sets to preserve store immutability. + new_payloads: dict[AttestationData, set[TypeOneMultiSignature]] = { + k: set(v) for k, v in store.latest_new_aggregated_payloads.items() + } + aggregates: list[SignedAggregatedAttestation] = [] + + for att in block_attestations: + data = att.data + + # Only spend a split on attestations that can still move + # justification forward. A target at or behind the store's + # justified checkpoint cannot, so skip it. + if data.target.slot <= store.latest_justified.slot: + continue + + data_root = hash_tree_root(data) + block_participants = set(att.aggregation_bits.to_validator_indices()) + + local_proofs = local_proofs_by_root.get(data_root, []) + local_union: set = set() + for proof in local_proofs: + local_union |= set(proof.participants.to_validator_indices()) + + # Only act when the block covers validators we do not already + # hold. An empty local_union also covers data never seen locally. + if not (block_participants - local_union): + continue + + try: + block_t1 = type_two.split_by_msg( + message=data_root, + public_keys_per_message=public_keys_per_message, + ) + # split_by_msg returns an empty participant bitfield; restore + # the bits from the block attestation this component binds. + block_t1 = block_t1.model_copy(update={"participants": att.aggregation_bits}) + + if local_proofs: + combined = TypeOneMultiSignature.aggregate( + children=[ + ( + child, + [ + validators[vid].get_attestation_pubkey() + for vid in child.participants.to_validator_indices() + ], + ) + for child in (block_t1, *local_proofs) + ], + raw_xmss=[], + xmss_participants=None, + message=data_root, + slot=data.slot, + ) + else: + # Data unseen locally: nothing to merge, use as-is. + combined = block_t1 + except (AggregationError, AssertionError, KeyError, ValueError) as exc: + logger.debug("Post-block re-aggregation failed for %s: %s", data_root, exc) + continue + + # The combined proof is a superset of every local partial that + # fed it, so those partials are now redundant. Drop them from + # the pool (across any data key sharing this root) and keep only + # the higher-coverage proof. + if local_proofs: + superseded = set(local_proofs) + for key in list(new_payloads): + if hash_tree_root(key) != data_root: + continue + remaining = new_payloads[key] - superseded + if remaining: + new_payloads[key] = remaining + else: + del new_payloads[key] + + new_payloads.setdefault(data, set()).add(combined) + aggregates.append(SignedAggregatedAttestation(data=data, proof=combined)) + + if aggregates: + store = store.model_copy(update={"latest_new_aggregated_payloads": new_payloads}) + + return store, aggregates + + async def _publish_pending_block_aggregates(self) -> None: + """Gossip the aggregates recovered from processed blocks. + + Every processed block is deconstructed in the block wrapper, which + writes the recovered proofs into the store and queues the combined + aggregates here when this node acts as an aggregator. This drains + that queue onto the network. + """ + if not self._pending_block_aggregates: + return + pending = self._pending_block_aggregates + self._pending_block_aggregates = [] + for signed_attestation in pending: + await self.publish_aggregated_attestation(signed_attestation) + async def _check_sync_complete(self) -> None: """Move to SYNCED once head has reached finalized and no orphans remain.""" if self.state != SyncState.SYNCING: diff --git a/src/lean_spec/subspecs/validator/service.py b/src/lean_spec/subspecs/validator/service.py index 2075eb5fa..d3b0de0a2 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.containers import Signature -from lean_spec.types import Bytes32, Slot, Uint64, ValidatorIndex +from lean_spec.subspecs.xmss.aggregation import TypeOneMultiSignature, TypeTwoMultiSignature +from lean_spec.subspecs.xmss.containers import PublicKey, Signature +from lean_spec.types import ByteList512KiB, 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,60 @@ 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. + # The pubkey lookup below indexes the active validator set, so each + # participant must fall within it. + # A stale partial aggregate would otherwise blow up deep inside + # the aggregator with an opaque KeyError. + num_validators = Uint64(len(validators)) + public_keys_per_part: list[list[PublicKey]] = [] + for proof in attestation_proofs: + part_pubkeys: list[PublicKey] = [] + for vid in proof.participants.to_validator_indices(): + if not vid.is_valid(num_validators): + raise ValueError( + f"Attestation proof references validator {vid}; " + f"active set has {num_validators} validators" + ) + part_pubkeys.append(validators[vid].get_attestation_pubkey()) + public_keys_per_part.append(part_pubkeys) + 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=ByteList512KiB(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..4df52745c 100644 --- a/src/lean_spec/subspecs/xmss/aggregation.py +++ b/src/lean_spec/subspecs/xmss/aggregation.py @@ -1,27 +1,36 @@ -"""Signature aggregation for the Lean Ethereum consensus specification.""" +""" +Multi-signature aggregation for the Lean Ethereum consensus spec. + +Two proof shapes: + +- Type-1: many validators on one message (one AttestationData, or one block root). +- Type-2: a merge of N Type-1 proofs over distinct messages. +""" 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, + verify_type_1, + verify_type_2_with_messages, ) from lean_spec.config import LEAN_ENV, LeanEnvMode from lean_spec.types import ( AggregationBits, - ByteListMiB, + ByteList512KiB, Bytes32, Container, Slot, ValidatorIndex, ValidatorIndices, ) +from lean_spec.types.boolean import Boolean from .containers import PublicKey, Signature @@ -40,55 +49,70 @@ class AggregationError(Exception): - """Raised when signature aggregation or verification fails.""" + """Raised when signature aggregation, merging, splitting, or verification fails.""" -class AggregatedSignatureProof(Container): - """ - 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. +class TypeOneMultiSignature(Container): + """A single-message proof aggregating signatures from many validators. - 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. + The signed message and slot are rederived by the verifier from the + block body it already trusts, so they live outside the proof envelope. """ participants: AggregationBits - """Bitfield indicating which validators' signatures are included.""" + """Bitfield indicating which validators contributed signatures.""" + + proof: ByteList512KiB + """Aggregated proof bytes in compact no-pubkeys representation.""" + + @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. + """ + selected: list[TypeOneMultiSignature] = [] + covered: set[ValidatorIndex] = set() + + for proofs in proof_sets: + if not proofs: + continue - proof_data: ByteListMiB - """The raw aggregated proof bytes from leanVM.""" + remaining = list(proofs) - @classmethod + while remaining: + best = max( + remaining, + key=lambda p: len(set(p.participants.to_validator_indices()) - covered), + ) + new_coverage = set(best.participants.to_validator_indices()) - covered + + 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 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 +133,207 @@ 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.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 = child.participants.data.count(Boolean(1)) + 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, + _, 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( - participants=participants, - proof_data=ByteListMiB(data=proof_bytes), + except Exception as exc: + raise AggregationError(f"Type-1 aggregation failed: {exc}") from exc + + return TypeOneMultiSignature( + participants=participants, + proof=ByteList512KiB(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.""" + mode = mode or LEAN_ENV + setup_prover(mode=mode) + + expected = self.participants.data.count(Boolean(1)) + 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] + try: + verify_type_1( + pks_ssz, + bytes(message), + int(slot), + bytes(self.proof.data), + 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 - @staticmethod - def select_greedily( - *proof_sets: set[AggregatedSignatureProof] | None, - ) -> tuple[list[AggregatedSignatureProof], set[ValidatorIndex]]: - """ - Greedy set-cover selection of proofs to maximize validator coverage. - Repeatedly selects the proof covering the most uncovered validators - until no proof adds new coverage. Earlier proof sets are prioritized. +class TypeTwoMultiSignature(Container): + """A merged proof covering many distinct messages. - TODO: We should find a better place for this in the future. + On the wire a SignedBlock carries the SSZ-serialised form of this + container as its single proof blob. + """ - Args: - proof_sets: Candidate proof sets in priority order. + proof: ByteList512KiB + """Compact no-pubkeys serialized Type-2 proof bytes.""" - Returns: - Selected proofs and the set of covered validator indices. + @staticmethod + 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. """ - selected: list[AggregatedSignatureProof] = [] - covered: set[ValidatorIndex] = set() + if not parts: + raise AggregationError("Type-2 aggregate requires at least one Type-1 input") - # 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 + mode = mode or LEAN_ENV + setup_prover(mode=mode) + log_inv_rate = LOG_INV_RATE_TEST if mode == "test" else LOG_INV_RATE_PROD - remaining = list(proofs) + 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)}" + ) - # 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), + type1_entries: list[tuple[list[bytes], bytes]] = [] + for idx, part in enumerate(parts): + expected = part.participants.data.count(Boolean(1)) + if public_keys_per_part is None: + raise AggregationError( + "public_keys_per_part is required when Type-1 proofs are stored without pubkeys" ) - new_coverage = set(best.participants.to_validator_indices()) - covered - - # No proof in this tier adds new coverage. - # Remaining proofs are fully redundant with what we already have. - if not new_coverage: - break + 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))) - selected.append(best) - covered |= new_coverage - remaining.remove(best) + try: + _, 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 - return selected, covered + return TypeTwoMultiSignature(proof=ByteList512KiB(data=type2_wire)) - def verify( + def split_by_msg( self, - public_keys: Sequence[PublicKey], message: Bytes32, - slot: Slot, + public_keys_per_message: Sequence[Sequence[PublicKey]], mode: LeanEnvMode | None = None, - ) -> None: + ) -> TypeOneMultiSignature: + """Recover the Type-1 proof bound to a specific message from this Type-2 merge. + + public_keys_per_message defines the per-component pubkey layout the + Type-2 was built with. """ - Verify this aggregated signature proof. + mode = mode or LEAN_ENV + setup_prover(mode=mode) + log_inv_rate = LOG_INV_RATE_TEST if mode == "test" else LOG_INV_RATE_PROD - 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). + pub_keys_per_component_ssz: list[list[bytes]] = [ + [pk.encode_bytes() for pk in pks] for pks in public_keys_per_message + ] - Raises: - AggregationError: If verification fails. + try: + _, type1_wire = split_type_2_by_msg( + pub_keys_per_component_ssz, + bytes(self.proof.data), + bytes(message), + log_inv_rate, + mode=mode, + ) + except Exception as exc: + raise AggregationError(f"Type-2 split-by-message failed: {exc}") from exc + + return TypeOneMultiSignature( + participants=AggregationBits(data=[]), + proof=ByteList512KiB(data=type1_wire), + ) + + def verify( + self, + public_keys_per_message: Sequence[Sequence[PublicKey]], + messages: Sequence[tuple[Bytes32, Slot]], + mode: LeanEnvMode | None = None, + ) -> None: + """Verify this multi-message Type-2 proof. + + Each entry of public_keys_per_message corresponds to one Type-1 + component merged into this Type-2. + The parallel messages entry binds that component to a specific + message hash and slot. + Without this binding the proof would verify against any attacker + chosen attestation data that resolves to the same pubkeys. """ mode = mode or LEAN_ENV - setup_verifier(mode=mode) + setup_prover(mode=mode) + + if len(messages) != len(public_keys_per_message): + raise AggregationError( + f"Type-2 verify expected {len(public_keys_per_message)} message bindings, " + f"got {len(messages)}" + ) + + pub_keys_per_component_ssz: list[list[bytes]] = [ + [pk.encode_bytes() for pk in pks] for pks in public_keys_per_message + ] + expected_messages = [(bytes(msg), int(slot)) for msg, slot in messages] try: - verify_aggregated_signatures( - [pk.encode_bytes() for pk in public_keys], - message, - self.proof_data.encode_bytes(), - slot, + verify_type_2_with_messages( + pub_keys_per_component_ssz, + expected_messages, + bytes(self.proof.data), 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 diff --git a/src/lean_spec/types/__init__.py b/src/lean_spec/types/__init__.py index 43f58acca..b0e037b0a 100644 --- a/src/lean_spec/types/__init__.py +++ b/src/lean_spec/types/__init__.py @@ -7,7 +7,7 @@ ZERO_HASH, BaseByteList, BaseBytes, - ByteListMiB, + ByteList512KiB, Bytes1, Bytes4, Bytes12, @@ -55,7 +55,7 @@ "Bytes52", "Bytes64", "Bytes65", - "ByteListMiB", + "ByteList512KiB", "ZERO_HASH", "CamelModel", "StrictBaseModel", diff --git a/src/lean_spec/types/byte_arrays.py b/src/lean_spec/types/byte_arrays.py index 9b5be7e85..82f197582 100644 --- a/src/lean_spec/types/byte_arrays.py +++ b/src/lean_spec/types/byte_arrays.py @@ -438,7 +438,7 @@ def hex(self) -> str: return self.data.hex() -class ByteListMiB(BaseByteList): - """Variable-length byte list with a limit of 1 MiB (1024 * 1024 bytes).""" +class ByteList512KiB(BaseByteList): + """Variable-length byte list with a 512 KiB limit.""" - LIMIT = 1024 * 1024 + LIMIT = 512 * 1024 diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 61d7c07c5..8dbf208aa 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 ByteList512KiB, 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=ByteList512KiB(data=b""), ) anchor_root = hash_tree_root(genesis.block) diff --git a/tests/consensus/lstar/fc/test_attestation_target_selection.py b/tests/consensus/lstar/fc/test_attestation_target_selection.py index 5060dc89b..74a44e950 100644 --- a/tests/consensus/lstar/fc/test_attestation_target_selection.py +++ b/tests/consensus/lstar/fc/test_attestation_target_selection.py @@ -426,25 +426,10 @@ def test_attestation_target_justifiable_constraint( 13: 9, # delta = 13 - 3 - 0 = 10 14: 9, # delta = 14 - 3 - 0 = 11 15: 12, # delta = 15 - 3 - 0 = 12, Rule 3: pronic number (3*4) - 16: 12, # delta = 16 - 3 - 0 = 13 - 17: 12, # delta = 17 - 3 - 0 = 14 - 18: 12, # delta = 18 - 3 - 0 = 15 - 19: 16, # delta = 19 - 3 - 0 = 16, Rule 2: perfect square (4^2) - 20: 16, # delta = 20 - 3 - 0 = 17 - 21: 16, # delta = 21 - 3 - 0 = 18 - 22: 16, # delta = 22 - 3 - 0 = 19 - 23: 20, # delta = 23 - 3 - 0 = 20, Rule 3: pronic number (4*5) - 24: 20, # delta = 24 - 3 - 0 = 21 - 25: 20, # delta = 25 - 3 - 0 = 22 - 26: 20, # delta = 26 - 3 - 0 = 23 - 27: 20, # delta = 27 - 3 - 0 = 24 - 28: 25, # delta = 28 - 3 - 0 = 25, Rule 2: perfect square (5^2) - 29: 25, # delta = 29 - 3 - 0 = 26 - 30: 25, # delta = 30 - 3 - 0 = 27 } steps = [] - for i in range(1, 31): + for i in range(1, 16): steps.append( BlockStep( block=BlockSpec( diff --git a/tests/consensus/lstar/fc/test_block_attestation_limits.py b/tests/consensus/lstar/fc/test_block_attestation_limits.py index 0903d5121..898cbcb8f 100644 --- a/tests/consensus/lstar/fc/test_block_attestation_limits.py +++ b/tests/consensus/lstar/fc/test_block_attestation_limits.py @@ -23,7 +23,7 @@ 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 + key state poisons the cache for any later test on the same worker that need low-slot signatures. """ XmssKeyManager.reset_signing_state() @@ -46,7 +46,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 distinct entriesis accepted by the store. Scenario -------- @@ -107,7 +107,7 @@ 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 MAX_ATTESTATIONS_DATA + 1 distinct entries is rejected. Scenario -------- diff --git a/tests/consensus/lstar/fc/test_block_production.py b/tests/consensus/lstar/fc/test_block_production.py index d46e861f0..e01bd78b1 100644 --- a/tests/consensus/lstar/fc/test_block_production.py +++ b/tests/consensus/lstar/fc/test_block_production.py @@ -272,7 +272,8 @@ 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 ------------------- diff --git a/tests/consensus/lstar/ssz/test_basic_types.py b/tests/consensus/lstar/ssz/test_basic_types.py index ea1ce3070..8a9c2be5c 100644 --- a/tests/consensus/lstar/ssz/test_basic_types.py +++ b/tests/consensus/lstar/ssz/test_basic_types.py @@ -11,7 +11,7 @@ BaseBitlist, BaseBitvector, Boolean, - ByteListMiB, + ByteList512KiB, Bytes4, Bytes32, Bytes52, @@ -236,22 +236,22 @@ def test_bytes64_typical(ssz: SSZTestFiller) -> None: ssz(type_name="Bytes64", value=Bytes64(b"\xef" * 64)) -# --- ByteListMiB --- +# --- ByteList512KiB --- def test_bytelist_empty(ssz: SSZTestFiller) -> None: """Empty byte list. Zero-length content with length mix-in of zero.""" - ssz(type_name="ByteListMiB", value=ByteListMiB(data=b"")) + ssz(type_name="ByteList512KiB", value=ByteList512KiB(data=b"")) def test_bytelist_small(ssz: SSZTestFiller) -> None: """Byte list with 4 bytes. Fits within a single 32-byte chunk.""" - ssz(type_name="ByteListMiB", value=ByteListMiB(data=b"\x01\x02\x03\x04")) + ssz(type_name="ByteList512KiB", value=ByteList512KiB(data=b"\x01\x02\x03\x04")) def test_bytelist_medium(ssz: SSZTestFiller) -> None: """Byte list with 256 bytes. Spans 8 full chunks.""" - ssz(type_name="ByteListMiB", value=ByteListMiB(data=bytes(range(256)))) + ssz(type_name="ByteList512KiB", value=ByteList512KiB(data=bytes(range(256)))) # --- Bitvector --- diff --git a/tests/consensus/lstar/ssz/test_consensus_containers.py b/tests/consensus/lstar/ssz/test_consensus_containers.py index 333931ddf..d0cdff956 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,11 +26,11 @@ 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 TypeOneMultiSignature from lean_spec.types import ( AggregationBits, Boolean, - ByteListMiB, + ByteList512KiB, Bytes32, Bytes52, Checkpoint, @@ -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=ByteList512KiB(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=ByteList512KiB(data=b"\xde\xad\xbe\xef")), ) @@ -446,13 +421,14 @@ 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( + data=data, + proof=TypeOneMultiSignature( participants=AggregationBits(data=[Boolean(True)]), - proof_data=ByteListMiB(data=b""), + proof=ByteList512KiB(data=b""), ), ), ) @@ -460,15 +436,17 @@ 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( + data=data, + proof=TypeOneMultiSignature( participants=AggregationBits( data=[Boolean(True), Boolean(False), Boolean(True), Boolean(True)] ), - proof_data=ByteListMiB(data=b"\xca\xfe\xba\xbe\xde\xad"), + proof=ByteList512KiB(data=wire), ), ), ) diff --git a/tests/consensus/lstar/ssz/test_xmss_containers.py b/tests/consensus/lstar/ssz/test_xmss_containers.py index 0fe81773d..4b10ded57 100644 --- a/tests/consensus/lstar/ssz/test_xmss_containers.py +++ b/tests/consensus/lstar/ssz/test_xmss_containers.py @@ -6,7 +6,10 @@ 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 ( + TypeOneMultiSignature, + TypeTwoMultiSignature, +) from lean_spec.subspecs.xmss.types import ( HASH_DIGEST_LENGTH, HashDigestList, @@ -18,7 +21,7 @@ from lean_spec.types import ( AggregationBits, Boolean, - ByteListMiB, + ByteList512KiB, Bytes32, Slot, Uint64, @@ -69,48 +72,43 @@ def test_signature_actual(ssz: SSZTestFiller) -> None: ssz(type_name="Signature", value=signature) -# --- AggregatedSignatureProof --- +# --- TypeOneMultiSignature / TypeTwoMultiSignature --- + +def _bits(participants: list[bool]) -> AggregationBits: + """Build a participant bitfield from a list of booleans.""" + return AggregationBits(data=[Boolean(b) for b in participants]) -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( + participants=_bits([True]), + proof=ByteList512KiB(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( + participants=_bits([True, False, True]), + proof=ByteList512KiB(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 envelope.""" + wire = b"\x01\x02\x03" 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(proof=ByteList512KiB(data=wire)), ) diff --git a/tests/consensus/lstar/verify_signatures/test_structural_rejections.py b/tests/consensus/lstar/verify_signatures/test_structural_rejections.py index e28901b32..f02450562 100644 --- a/tests/consensus/lstar/verify_signatures/test_structural_rejections.py +++ b/tests/consensus/lstar/verify_signatures/test_structural_rejections.py @@ -1,64 +1,117 @@ -"""Signature verification: structural-invariant rejection vectors. +"""Signature verification: structural rejection vectors for the merged proof. -Exercises rejection paths that lie behind structural invariants the -block builder normally upholds. The verify_signatures fixture's tamper -hook mutates a validly built signed block so the rejection path fires -on verify. +These cover structural invariants a peer could break that the block +builder upholds by construction: + +- the merged proof must decode, +- its component count must match the body plus the proposer, +- each component stays bound to the message it signed. """ import pytest from consensus_testing import ( - AggregatedAttestationSpec, BlockSpec, VerifySignaturesTestFiller, generate_pre_state, ) -from lean_spec.types import Slot, ValidatorIndex +from lean_spec.types import Slot pytestmark = pytest.mark.valid_until("Lstar") -def test_signature_group_count_mismatch_rejected( +def test_corrupt_proof_rejected( + verify_signatures_test: VerifySignaturesTestFiller, +) -> None: + """A signed block whose merged proof does not decode is rejected. + + Scenario + -------- + - Anchor state has 1 validator. + - Block at slot 1 carries only the proposer component. + - The tamper hook overwrites the merged proof with a short blob. + + Expected Behavior + ----------------- + Verification fails with AssertionError because the Type-2 envelope + cannot be decoded. + + Why This Matters + ---------------- + The proof blob arrives over the wire. + A peer can send arbitrary bytes. + Clients must reject before attempting to verify a malformed proof. + """ + verify_signatures_test( + anchor_state=generate_pre_state(num_validators=1), + block=BlockSpec(slot=Slot(1), attestations=[]), + tamper={"operation": "corrupt_proof"}, + expect_exception=AssertionError, + ) + + +def test_proof_component_count_mismatch_rejected( verify_signatures_test: VerifySignaturesTestFiller, ) -> None: - """A signed block with fewer signature groups than attestations is rejected. + """A block whose body claims more components than the proof is rejected. Scenario -------- - - Anchor state has 4 validators. - - Block at slot 2 carries one aggregated attestation. - - After the block is built, the tamper hook drops the last signature - group, leaving one attestation and zero signature groups. + - Anchor state has 1 validator. + - Block at slot 1 carries only the proposer component. + - The tamper hook appends a body attestation that has no matching + proof component. Expected Behavior ----------------- - Signature verification fails with AssertionError: - "Attestation signature groups must align with block body attestations" + Verification fails with AssertionError: the proof component count no + longer matches the body plus the proposer. Why This Matters ---------------- - Pins a structural check the block builder enforces by construction: + The merged proof and the body attestation list are parallel. + A peer could add body entries to credit votes the proof never + carried. + Clients must reject any count mismatch. + """ + verify_signatures_test( + anchor_state=generate_pre_state(num_validators=1), + block=BlockSpec(slot=Slot(1), attestations=[]), + tamper={"operation": "append_phantom_attestation"}, + expect_exception=AssertionError, + ) + + +def test_proof_reused_under_different_message_rejected( + verify_signatures_test: VerifySignaturesTestFiller, +) -> None: + """An honest proof reused under a different block is rejected. - - Each aggregated attestation must have a corresponding signature. - - A peer could send a malformed signed block with missing signature - groups; the receiving client must reject it before verifying any - individual signature. + Scenario + -------- + - Anchor state has 1 validator. + - Block at slot 1 is signed honestly. + - The tamper hook rewrites the block's state root after signing, so + the block root differs while the proof is unchanged. + + Expected Behavior + ----------------- + Verification fails with AssertionError: each proof component is bound + to the message it signed, and the proposer component no longer + matches the recomputed block root. + + Why This Matters + ---------------- + Without the per-component binding, honest signatures could be lifted + onto attacker-chosen block or attestation data that resolves to the + same public keys. + Validators would be credited for messages they never signed, directly + attackable for justification manipulation. + This pins the binding that closes that hole. """ verify_signatures_test( - anchor_state=generate_pre_state(num_validators=3), - block=BlockSpec( - slot=Slot(1), - attestations=[ - AggregatedAttestationSpec( - validator_ids=[ValidatorIndex(0)], - slot=Slot(1), - target_slot=Slot(0), - target_root_label="genesis", - valid_signature=False, - ), - ], - ), - tamper={"operation": "drop_last_signature"}, + anchor_state=generate_pre_state(num_validators=1), + block=BlockSpec(slot=Slot(1), attestations=[]), + tamper={"operation": "mutate_state_root"}, expect_exception=AssertionError, ) 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..7215da367 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 ( + ByteList512KiB, + 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.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=ByteList512KiB(data=merged.encode_bytes()), ) # Process block via on_block on a fresh consumer store diff --git a/tests/lean_spec/forks/lstar/forkchoice/test_block_production_justification_gap.py b/tests/lean_spec/forks/lstar/forkchoice/test_block_production_justification_gap.py index fe10e47e2..df8b130ed 100644 --- a/tests/lean_spec/forks/lstar/forkchoice/test_block_production_justification_gap.py +++ b/tests/lean_spec/forks/lstar/forkchoice/test_block_production_justification_gap.py @@ -40,7 +40,7 @@ def test_produce_block_on_head_with_lagging_justification( def add_block(block_spec: BlockSpec) -> None: """Build the spec'd block on the current store and apply it.""" nonlocal store - signed_block = block_spec.build_signed_block_with_store( + signed_block, store = block_spec.build_signed_block_with_store( store, block_registry, key_manager, "test" ) if block_spec.label is not None: 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 2bcc04ce0..fcf9cf2c0 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 @@ -8,17 +8,18 @@ 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.types import Bytes32, Checkpoint, Slot, ValidatorIndex, ValidatorIndices -from lean_spec.types.byte_arrays import ByteListMiB +from lean_spec.subspecs.xmss.aggregation import TypeOneMultiSignature +from lean_spec.types import Checkpoint, Slot, ValidatorIndex, ValidatorIndices +from lean_spec.types.byte_arrays import ByteList512KiB, Bytes32 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( +def _make_empty_proof(participants: list[ValidatorIndex]) -> TypeOneMultiSignature: + """Create a placeholder Type-1 proof carrying a participant bitfield.""" + placeholder = ByteList512KiB(data=b"") + return TypeOneMultiSignature( participants=ValidatorIndices(data=participants).to_aggregation_bits(), - proof_data=ByteListMiB(data=b""), + 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 025587489..496895b31 100644 --- a/tests/lean_spec/forks/lstar/forkchoice/test_store_attestations.py +++ b/tests/lean_spec/forks/lstar/forkchoice/test_store_attestations.py @@ -15,9 +15,9 @@ 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, + ByteList512KiB, Bytes32, Checkpoint, Slot, @@ -302,7 +302,7 @@ def test_valid_proof_stored_correctly( strict=True, ) ) - proof = AggregatedSignatureProof.aggregate( + proof = TypeOneMultiSignature.aggregate( xmss_participants=xmss_participants, children=[], raw_xmss=raw_xmss, @@ -349,7 +349,7 @@ def test_attestation_data_used_as_key( strict=True, ) ) - proof = AggregatedSignatureProof.aggregate( + proof = TypeOneMultiSignature.aggregate( xmss_participants=xmss_participants, children=[], raw_xmss=raw_xmss, @@ -389,7 +389,7 @@ def test_invalid_proof_rejected(self, key_manager: XmssKeyManager, spec: LstarSp strict=True, ) ) - proof = AggregatedSignatureProof.aggregate( + proof = TypeOneMultiSignature.aggregate( xmss_participants=xmss_participants, children=[], raw_xmss=raw_xmss, @@ -398,13 +398,11 @@ def test_invalid_proof_rejected(self, key_manager: XmssKeyManager, spec: LstarSp ) # Corrupt the proof data - corrupted_data = bytearray(proof.proof_data.encode_bytes()) + corrupted_data = bytearray(proof.proof.data) corrupted_data[10] ^= 0xFF corrupted_data[20] ^= 0xFF - corrupted_proof = AggregatedSignatureProof( - participants=proof.participants, - proof_data=ByteListMiB(data=bytes(corrupted_data)), - ) + corrupted_blob = ByteList512KiB(data=bytes(corrupted_data)) + corrupted_proof = proof.model_copy(update={"proof": corrupted_blob}) signed_aggregated = SignedAggregatedAttestation( data=attestation_data, @@ -440,7 +438,7 @@ def test_multiple_proofs_accumulate(self, key_manager: XmssKeyManager, spec: Lst strict=True, ) ) - proof_1 = AggregatedSignatureProof.aggregate( + proof_1 = TypeOneMultiSignature.aggregate( xmss_participants=xmss_1, children=[], raw_xmss=raw_xmss_1, @@ -461,7 +459,7 @@ def test_multiple_proofs_accumulate(self, key_manager: XmssKeyManager, spec: Lst strict=True, ) ) - proof_2 = AggregatedSignatureProof.aggregate( + proof_2 = TypeOneMultiSignature.aggregate( xmss_participants=xmss_2, children=[], raw_xmss=raw_xmss_2, @@ -546,7 +544,7 @@ def test_aggregated_proof_is_valid(self, key_manager: XmssKeyManager, spec: Lsta participants = proof.participants.to_validator_indices() public_keys = [key_manager[vid].attestation_keypair.public_key for vid in participants] - # Verify the proof is valid + # Verify proof is valid proof.verify( public_keys=public_keys, message=hash_tree_root(attestation_data), @@ -774,7 +772,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) @@ -814,6 +811,6 @@ def test_gossip_to_aggregation_to_storage( proof.verify( public_keys=public_keys, - message=data_root, + message=hash_tree_root(attestation_data), slot=attestation_data.slot, ) 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..03246c2ef 100644 --- a/tests/lean_spec/forks/lstar/forkchoice/test_store_pruning.py +++ b/tests/lean_spec/forks/lstar/forkchoice/test_store_pruning.py @@ -2,8 +2,8 @@ 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.types import ByteListMiB, Bytes32, Slot, ValidatorIndex, ValidatorIndices +from lean_spec.subspecs.xmss.aggregation import TypeOneMultiSignature +from lean_spec.types import ByteList512KiB, Bytes32, Slot, ValidatorIndex, ValidatorIndices from tests.lean_spec.helpers import ( make_attestation_data, make_bytes32, @@ -137,9 +137,10 @@ def test_prunes_related_structures_together(spec: LstarSpec, pruning_store: Stor ) # Create mock aggregated proof (empty proof data for testing) - mock_proof = AggregatedSignatureProof( + placeholder = ByteList512KiB(data=b"") + mock_proof = TypeOneMultiSignature( participants=ValidatorIndices(data=[ValidatorIndex(1)]).to_aggregation_bits(), - proof_data=ByteListMiB(data=b""), + 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 1da6c6d3b..9d8404774 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) @@ -247,7 +247,7 @@ 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_keypair.public_key for vid in participants] 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 560a14101..cf3599c0a 100644 --- a/tests/lean_spec/forks/lstar/state/test_state_aggregation.py +++ b/tests/lean_spec/forks/lstar/state/test_state_aggregation.py @@ -53,9 +53,7 @@ def test_aggregated_signatures_prefers_full_gossip_payload( 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, + public_keys=public_keys, message=hash_tree_root(att_data), slot=att_data.slot ) @@ -149,7 +147,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 @@ -188,7 +185,9 @@ def test_aggregated_signatures_with_multiple_data_groups( 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] + public_keys = [ + container_key_manager[vid].attestation_keypair.public_key for vid in participants + ] signed_att.proof.verify( public_keys=public_keys, message=hash_tree_root(signed_att.data), diff --git a/tests/lean_spec/helpers/builders.py b/tests/lean_spec/helpers/builders.py index d2a9b4648..b876239a0 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 ( + ByteList512KiB, Bytes32, Bytes52, Checkpoint, @@ -222,13 +219,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=ByteList512KiB(data=b"")) def make_aggregated_attestation( @@ -430,8 +421,8 @@ 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.""" data_root = hash_tree_root(attestation_data) xmss_participants = ValidatorIndices(data=participants).to_aggregation_bits() raw_xmss = list( @@ -441,7 +432,7 @@ def make_aggregated_proof( strict=True, ) ) - return AggregatedSignatureProof.aggregate( + return TypeOneMultiSignature.aggregate( xmss_participants=xmss_participants, children=[], raw_xmss=raw_xmss, @@ -480,18 +471,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.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=ByteList512KiB(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..c822df2a8 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 ByteList512KiB, 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=ByteList512KiB(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..e825ac9e8 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 ByteList512KiB, 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=ByteList512KiB(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..e3fb709c3 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 ByteList512KiB, 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=ByteList512KiB(data=b"")) class TestFetchFinalizedBlock: diff --git a/tests/lean_spec/subspecs/sync/test_reaggregate_from_block.py b/tests/lean_spec/subspecs/sync/test_reaggregate_from_block.py new file mode 100644 index 000000000..6f8843c26 --- /dev/null +++ b/tests/lean_spec/subspecs/sync/test_reaggregate_from_block.py @@ -0,0 +1,156 @@ +"""Tests for post-block Type-1 deconstruction in SyncService. + +Exercises `SyncService._deconstruct_block_into_store`: for every processed +block (gossip, head-sync, or backfilled), the merged Type-2 proof is split +into per-attestation Type-1 proofs, merged with locally held partials, and +written into the pending pool, replacing the partials it subsumes. + +Deconstruction only runs for an attestation when: + +- its target is ahead of the store's justified checkpoint, so the proof + can still help move justification, and +- it adds at least one participant the node does not already hold. + +Only the decision/gate paths are exercised here. The split itself +(`split_type_2_by_msg`) is implemented and works in the production prover, +but the upstream `lean_multisig` `test-config` build aborts it with an +in-circuit assertion (the reduced XMSS dimensions are inconsistent with +the aggregation program's split branch). It is not a leanSpec defect and +cannot be fixed here, so the split-extract-merge body is verified under a +production prover, not in this test-mode suite. +""" + +from __future__ import annotations + +from consensus_testing.keys import XmssKeyManager + +from lean_spec.forks.lstar.containers.attestation import AttestationData +from lean_spec.forks.lstar.spec import LstarSpec +from lean_spec.subspecs.networking import PeerId +from lean_spec.subspecs.ssz.hash import hash_tree_root +from lean_spec.types import Checkpoint, Slot, ValidatorIndex +from tests.lean_spec.helpers import ( + create_mock_sync_service, + make_aggregated_proof, + make_signed_block_from_store, + make_store, +) + +# Round-robin proposer is slot % num_validators with four validators. +NUM_VALIDATORS = 4 +CHAIN_SLOT = Slot(1) +CHAIN_PROPOSER = ValidatorIndex(1) +BLOCK_SLOT = Slot(2) +BLOCK_PROPOSER = ValidatorIndex(2) + + +def _setup( + key_manager: XmssKeyManager, + *, + block_participants: list[ValidatorIndex], +): + """Build a two-block chain and a signed block carrying an attestation. + + The chain block sits at slot 1. The returned signed block sits at slot + 2 and carries one attestation whose target is the slot-1 block, ahead + of the still-genesis justified checkpoint. The returned store holds the + slot-1 block and its state (the parent state the Type-2 pubkey layout + is resolved against) with the justified checkpoint still at genesis. + """ + spec = LstarSpec() + base_store = make_store( + num_validators=NUM_VALIDATORS, validator_id=ValidatorIndex(0), key_manager=key_manager + ) + + consumer_store, chain_block = make_signed_block_from_store( + base_store, key_manager, CHAIN_SLOT, CHAIN_PROPOSER + ) + chain_store = spec.on_block(consumer_store, chain_block) + chain_root = hash_tree_root(chain_block.block) + + # Target the slot-1 block; source stays at the genesis justified + # checkpoint so the builder accepts the attestation. + attestation_data = AttestationData( + slot=BLOCK_SLOT, + head=Checkpoint(root=chain_root, slot=CHAIN_SLOT), + target=Checkpoint(root=chain_root, slot=CHAIN_SLOT), + source=chain_store.latest_justified, + ) + + block_proof = make_aggregated_proof(key_manager, block_participants, attestation_data) + producer_store = chain_store.model_copy( + update={"latest_known_aggregated_payloads": {attestation_data: {block_proof}}} + ) + _, signed_block = make_signed_block_from_store( + producer_store, key_manager, BLOCK_SLOT, BLOCK_PROPOSER + ) + return chain_store, signed_block, attestation_data + + +def _service(peer_id: PeerId): + """A SyncService usable to invoke the deconstruction core directly.""" + return create_mock_sync_service(peer_id) + + +def test_skips_when_target_not_ahead_of_justified( + peer_id: PeerId, key_manager: XmssKeyManager +) -> None: + """Target at or behind the justified checkpoint -> no aggregates. + + The block's attestation cannot move justification, so the expensive + split is never attempted and the store is returned unchanged. + """ + chain_store, signed_block, attestation_data = _setup( + key_manager, block_participants=[ValidatorIndex(1), ValidatorIndex(2)] + ) + # Justified now sits at the attestation's target slot. + store = chain_store.model_copy(update={"latest_justified": attestation_data.target}) + service = _service(peer_id) + + new_store, aggregates = service._deconstruct_block_into_store(store, signed_block) + + assert aggregates == [] + assert new_store is store + + +def test_skips_when_block_adds_no_new_validators( + peer_id: PeerId, key_manager: XmssKeyManager +) -> None: + """Block participants are a subset of the local union -> no aggregates. + + The target is ahead of justified, so the only thing stopping the split + is that the block adds no new participant. The store is unchanged. + """ + block_participants = [ValidatorIndex(1), ValidatorIndex(2)] + chain_store, signed_block, attestation_data = _setup( + key_manager, block_participants=block_participants + ) + + local_partial = make_aggregated_proof( + key_manager, + [ValidatorIndex(1), ValidatorIndex(2), ValidatorIndex(3)], + attestation_data, + ) + store = chain_store.model_copy( + update={"latest_new_aggregated_payloads": {attestation_data: {local_partial}}} + ) + service = _service(peer_id) + + new_store, aggregates = service._deconstruct_block_into_store(store, signed_block) + + assert aggregates == [] + assert new_store is store + + +def test_noop_when_parent_state_missing(peer_id: PeerId, key_manager: XmssKeyManager) -> None: + """Without the parent state the pubkey layout cannot be resolved -> no-op.""" + chain_store, signed_block, _ = _setup( + key_manager, block_participants=[ValidatorIndex(1), ValidatorIndex(2)] + ) + store = chain_store.model_copy(update={"states": {}}) + service = _service(peer_id) + + new_store, aggregates = service._deconstruct_block_into_store(store, signed_block) + + assert aggregates == [] + assert new_store is store diff --git a/tests/lean_spec/subspecs/validator/test_service.py b/tests/lean_spec/subspecs/validator/test_service.py index 263c5d878..c485e51dc 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,18 @@ def test_wraps_the_input_block( assert result.block is block - def test_proposer_signature_is_cryptographically_valid( - self, sync_service: SyncService, key_manager: XmssKeyManager - ) -> None: - """The proposer signature verifies with the proposer's public key.""" - service, block = self._setup(sync_service, key_manager) - - result = service._sign_block(block, ValidatorIndex(0), []) - - public_key = key_manager[ValidatorIndex(0)].proposal_keypair.public_key - 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 - - def test_signs_block_root_with_proposal_key( - self, sync_service: SyncService, key_manager: XmssKeyManager - ) -> None: - """Block signing uses the proposal key and the signature covers the block root.""" - 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_keypair.public_key - assert TARGET_SIGNATURE_SCHEME.verify( - pk=proposal_pk, - slot=Slot(2), - message=hash_tree_root(block), - sig=result.signature.proposer_signature, - ) - - def test_attestation_signatures_included( + def test_attestation_proofs_merge_into_envelope( self, sync_service: SyncService, key_manager: XmssKeyManager, spec: LstarSpec ) -> None: - """Aggregated attestation proofs passed in are present in the returned signature.""" + """Aggregated attestation proofs are merged into the block envelope.""" 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) + TypeTwoMultiSignature.decode_bytes(result.proof.data) + assert result.block.proposer_index == ValidatorIndex(0) def test_missing_validator_raises_value_error( self, sync_service: SyncService, key_manager: XmssKeyManager @@ -976,17 +943,8 @@ 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_keypair.public_key - - 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 decode and the block carries the proposer index. + TypeTwoMultiSignature.decode_bytes(signed_block.proof.data) async def test_produce_real_attestation_with_valid_signature( self, @@ -1089,17 +1047,9 @@ 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_keypair.public_key - - 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 + # The merged proof decodes cleanly; the proposer identity now lives + # on the block, not inside the proof envelope. + TypeTwoMultiSignature.decode_bytes(signed_block.proof.data) async def test_block_includes_pending_attestations( self, @@ -1128,10 +1078,10 @@ async def test_block_includes_pending_attestations( public_keys.append(key_manager[vid].attestation_keypair.public_key) 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 +1111,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 decodes; its component count is rederived from + # the block body (one Type-1 per attestation plus the proposer). + TypeTwoMultiSignature.decode_bytes(signed_block.proof.data) 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 e7287601d..4a8bd8213 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, + TypeOneMultiSignature, + TypeTwoMultiSignature, +) +from lean_spec.types import ( + ByteList512KiB, + 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,20 +39,19 @@ def _sign_and_aggregate( strict=True, ) ) - proof = AggregatedSignatureProof.aggregate( + return TypeOneMultiSignature.aggregate( xmss_participants=xmss_participants, children=[], raw_xmss=raw_xmss, 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( + TypeOneMultiSignature.aggregate( xmss_participants=None, children=[], raw_xmss=[], @@ -52,7 +61,7 @@ def test_aggregate_rejects_empty_inputs() -> None: 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,7 +75,7 @@ def test_aggregate_multiple_signatures(key_manager: XmssKeyManager) -> None: ) ) - proof = AggregatedSignatureProof.aggregate( + proof = TypeOneMultiSignature.aggregate( xmss_participants=xmss_participants, children=[], raw_xmss=raw_xmss, @@ -77,11 +86,7 @@ def test_aggregate_multiple_signatures(key_manager: XmssKeyManager) -> None: assert set(proof.participants.to_validator_indices()) == set(vids) public_keys = [key_manager[vid].attestation_keypair.public_key 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,13 +111,14 @@ def test_aggregate_children_with_raw_signatures(key_manager: XmssKeyManager) -> ) ) - parent = AggregatedSignatureProof.aggregate( + parent = TypeOneMultiSignature.aggregate( xmss_participants=xmss_participants, children=[ ( child, [key_manager[ValidatorIndex(i)].attestation_keypair.public_key for i in range(2)], ) + for child in [child, child] ], raw_xmss=raw_xmss, message=hash_tree_root(att_data), @@ -123,11 +129,7 @@ def test_aggregate_children_with_raw_signatures(key_manager: XmssKeyManager) -> assert set(parent.participants.to_validator_indices()) == expected_vids public_keys = [key_manager[ValidatorIndex(i)].attestation_keypair.public_key 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: @@ -146,7 +148,7 @@ def test_aggregate_three_children(key_manager: XmssKeyManager) -> None: child_b_pks = [key_manager[ValidatorIndex(1)].attestation_keypair.public_key] child_c_pks = [key_manager[ValidatorIndex(2)].attestation_keypair.public_key] - parent = AggregatedSignatureProof.aggregate( + parent = TypeOneMultiSignature.aggregate( xmss_participants=None, children=[(child_a, child_a_pks), (child_b, child_b_pks), (child_c, child_c_pks)], raw_xmss=[], @@ -158,11 +160,7 @@ def test_aggregate_three_children(key_manager: XmssKeyManager) -> None: assert set(parent.participants.to_validator_indices()) == expected_vids public_keys = [key_manager[ValidatorIndex(i)].attestation_keypair.public_key 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: @@ -174,7 +172,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) @@ -185,15 +183,15 @@ def test_aggregate_children_of_children(key_manager: XmssKeyManager) -> None: leaf_c_pks = [key_manager[ValidatorIndex(2)].attestation_keypair.public_key] leaf_d_pks = [key_manager[ValidatorIndex(3)].attestation_keypair.public_key] - # Level 1: two intermediate proofs - mid_ab = AggregatedSignatureProof.aggregate( + # Level 1: two intermediate proofs. + mid_ab = TypeOneMultiSignature.aggregate( xmss_participants=None, children=[(leaf_a, leaf_a_pks), (leaf_b, leaf_b_pks)], raw_xmss=[], message=msg, slot=att_data.slot, ) - mid_cd = AggregatedSignatureProof.aggregate( + mid_cd = TypeOneMultiSignature.aggregate( xmss_participants=None, children=[(leaf_c, leaf_c_pks), (leaf_d, leaf_d_pks)], raw_xmss=[], @@ -201,8 +199,8 @@ def test_aggregate_children_of_children(key_manager: XmssKeyManager) -> None: slot=att_data.slot, ) - # Level 2: final root proof - root = AggregatedSignatureProof.aggregate( + # Level 2: final root proof. + root = TypeOneMultiSignature.aggregate( xmss_participants=None, children=[(mid_ab, leaf_a_pks + leaf_b_pks), (mid_cd, leaf_c_pks + leaf_d_pks)], raw_xmss=[], @@ -229,14 +227,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_keypair.public_key] child_b_pks = [key_manager[ValidatorIndex(1)].attestation_keypair.public_key] - # 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( @@ -247,7 +245,7 @@ def test_aggregate_mixed_children_and_raw_multiple(key_manager: XmssKeyManager) ) ) - proof = AggregatedSignatureProof.aggregate( + proof = TypeOneMultiSignature.aggregate( xmss_participants=xmss_participants, children=[(child_a, child_a_pks), (child_b, child_b_pks)], raw_xmss=raw_xmss, @@ -266,57 +264,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_keypair.public_key, - 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_keypair.public_key], - message=wrong_message, + message=make_bytes32(123), 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_keypair.public_key, - 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( @@ -332,23 +301,16 @@ 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 = ByteList512KiB(data=bytes(corrupted_bytes)) + corrupted = proof.model_copy(update={"proof": corrupted_blob}) with pytest.raises(AggregationError, match="verification failed"): - corrupted_proof.verify( + corrupted.verify( public_keys=[key_manager[vid].attestation_keypair.public_key], message=hash_tree_root(att_data), slot=att_data.slot, @@ -356,7 +318,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) @@ -364,17 +326,15 @@ 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_keypair.public_key] child_b_pks = [key_manager[ValidatorIndex(1)].attestation_keypair.public_key] - # Aggregation rejects children that signed different messages + # The binding rejects mismatching messages during recursive aggregation. with pytest.raises(AggregationError): - AggregatedSignatureProof.aggregate( + TypeOneMultiSignature.aggregate( xmss_participants=None, children=[(child_a, child_a_pks), (child_b, child_b_pks)], raw_xmss=[], @@ -385,14 +345,14 @@ 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( + placeholder = ByteList512KiB(data=b"\x00") + stub_child = TypeOneMultiSignature( participants=ValidatorIndices(data=[ValidatorIndex(0)]).to_aggregation_bits(), - proof_data=ByteListMiB(data=b"\x00"), + proof=placeholder, ) with pytest.raises(AggregationError, match="At least two child proofs"): - AggregatedSignatureProof.aggregate( + TypeOneMultiSignature.aggregate( xmss_participants=None, children=[ ( @@ -416,7 +376,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() @@ -428,10 +388,151 @@ def test_aggregate_rejects_mismatched_participant_count( ] with pytest.raises(AggregationError, match="does not match"): - AggregatedSignatureProof.aggregate( + TypeOneMultiSignature.aggregate( xmss_participants=xmss_participants, children=[], raw_xmss=raw_xmss, message=hash_tree_root(att_data), slot=att_data.slot, ) + + +def test_type_two_aggregate_rejects_empty_parts() -> None: + """Type-2 aggregation requires at least one Type-1 input.""" + with pytest.raises(AggregationError, match="at least one Type-1 input"): + TypeTwoMultiSignature.aggregate(parts=[], public_keys_per_part=[]) + + +def test_type_two_aggregate_rejects_mismatched_pubkey_layout( + key_manager: XmssKeyManager, +) -> None: + """The per-part pubkey layout must match the participant count of each part.""" + source = Checkpoint(root=make_bytes32(200), slot=Slot(0)) + att_args = (Slot(7), 201, 202, source) + + part = _sign_and_aggregate( + key_manager, + [ValidatorIndex(0), ValidatorIndex(1)], + att_args, + ) + # Layout claims one pubkey for a part that binds two participants. + wrong_layout = [[key_manager[ValidatorIndex(0)].attestation_keypair.public_key]] + + with pytest.raises(AggregationError, match="expected 2 pubkeys, got 1"): + TypeTwoMultiSignature.aggregate( + parts=[part], + public_keys_per_part=wrong_layout, + ) + + +def test_type_two_verify_round_trip(key_manager: XmssKeyManager) -> None: + """A Type-2 merge of two distinct-message Type-1 proofs round-trips through verify.""" + source = Checkpoint(root=make_bytes32(300), slot=Slot(0)) + + # Two distinct messages signed by disjoint validator sets. + att_args_a = (Slot(8), 301, 302, source) + att_args_b = (Slot(8), 303, 304, source) + att_data_a = make_attestation_data_simple( + att_args_a[0], + make_bytes32(att_args_a[1]), + make_bytes32(att_args_a[2]), + att_args_a[3], + ) + att_data_b = make_attestation_data_simple( + att_args_b[0], + make_bytes32(att_args_b[1]), + make_bytes32(att_args_b[2]), + att_args_b[3], + ) + + vids_a = [ValidatorIndex(0), ValidatorIndex(1)] + vids_b = [ValidatorIndex(2), ValidatorIndex(3)] + part_a = _sign_and_aggregate(key_manager, vids_a, att_args_a) + part_b = _sign_and_aggregate(key_manager, vids_b, att_args_b) + + pubkeys_a = [key_manager[vid].attestation_keypair.public_key for vid in vids_a] + pubkeys_b = [key_manager[vid].attestation_keypair.public_key for vid in vids_b] + + merged = TypeTwoMultiSignature.aggregate( + parts=[part_a, part_b], + public_keys_per_part=[pubkeys_a, pubkeys_b], + ) + + merged.verify( + public_keys_per_message=[pubkeys_a, pubkeys_b], + messages=[ + (hash_tree_root(att_data_a), att_data_a.slot), + (hash_tree_root(att_data_b), att_data_b.slot), + ], + ) + + +def test_type_two_verify_rejects_message_swap(key_manager: XmssKeyManager) -> None: + """Swapping the parallel message bindings causes verification to fail. + + Without per-component message binding a proposer could pair honest + signatures with attacker-chosen attestation data. + """ + source = Checkpoint(root=make_bytes32(400), slot=Slot(0)) + + att_args_a = (Slot(9), 401, 402, source) + att_args_b = (Slot(9), 403, 404, source) + att_data_a = make_attestation_data_simple( + att_args_a[0], + make_bytes32(att_args_a[1]), + make_bytes32(att_args_a[2]), + att_args_a[3], + ) + att_data_b = make_attestation_data_simple( + att_args_b[0], + make_bytes32(att_args_b[1]), + make_bytes32(att_args_b[2]), + att_args_b[3], + ) + + vids_a = [ValidatorIndex(0), ValidatorIndex(1)] + vids_b = [ValidatorIndex(2), ValidatorIndex(3)] + part_a = _sign_and_aggregate(key_manager, vids_a, att_args_a) + part_b = _sign_and_aggregate(key_manager, vids_b, att_args_b) + + pubkeys_a = [key_manager[vid].attestation_keypair.public_key for vid in vids_a] + pubkeys_b = [key_manager[vid].attestation_keypair.public_key for vid in vids_b] + + merged = TypeTwoMultiSignature.aggregate( + parts=[part_a, part_b], + public_keys_per_part=[pubkeys_a, pubkeys_b], + ) + + # Swap the parallel messages: part_a's pubkeys are now paired with part_b's + # message and vice versa. + with pytest.raises(AggregationError, match="verification failed"): + merged.verify( + public_keys_per_message=[pubkeys_a, pubkeys_b], + messages=[ + (hash_tree_root(att_data_b), att_data_b.slot), + (hash_tree_root(att_data_a), att_data_a.slot), + ], + ) + + +def test_type_two_verify_rejects_mismatched_messages_length( + key_manager: XmssKeyManager, +) -> None: + """messages must have the same length as public_keys_per_message.""" + source = Checkpoint(root=make_bytes32(500), slot=Slot(0)) + att_args = (Slot(10), 501, 502, source) + + vids = [ValidatorIndex(0), ValidatorIndex(1)] + part = _sign_and_aggregate(key_manager, vids, att_args) + pubkeys = [key_manager[vid].attestation_keypair.public_key for vid in vids] + + merged = TypeTwoMultiSignature.aggregate( + parts=[part], + public_keys_per_part=[pubkeys], + ) + + with pytest.raises(AggregationError, match="expected 1 message bindings, got 0"): + merged.verify( + public_keys_per_message=[pubkeys], + messages=[], + ) diff --git a/uv.lock b/uv.lock index f1a2f9fff..d75aadeca 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.5" +source = { git = "https://github.com/anshalshukla/leanMultisig-py?tag=v0.0.5#c1fee09c22ba0cc12a5fe915e9e5e677039ff310" } [[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.5" }, { 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.5" }, { 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.5" }, { name = "pycryptodome", specifier = ">=3.20.0,<4" }, { name = "pytest", specifier = ">=8.3.3,<9" }, { name = "pytest-asyncio", specifier = ">=1.0.0" },