Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions src/lean_spec/subspecs/xmss/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
from __future__ import annotations

from collections.abc import Mapping
from typing import TYPE_CHECKING, NamedTuple
from typing import TYPE_CHECKING, NamedTuple, override

from pydantic import model_serializer

from lean_spec.subspecs.containers.slot import Slot

from ...types import Bytes32, Uint64
from ...types.container import Container
from .constants import TARGET_CONFIG
from .subtree import HashSubTree
from .types import (
HashDigestList,
Expand Down Expand Up @@ -62,10 +63,12 @@ class Signature(Container):
- rho: Vector[Fp, RAND_LEN_FE]
- hashes: List[Vector[Fp, HASH_DIGEST_LENGTH], NODE_LIST_LIMIT]

This is a variable-size Container because path and hashes are variable-size
fields. The field dimensions are determined by the scheme parameters, so in
practice every valid signature serializes to the same byte count, but the SSZ
type system correctly classifies it as variable-size.
Although the fields are internally variable-size SSZ types, every valid
signature serializes to exactly `SIGNATURE_LEN_BYTES`. This class overrides
`is_fixed_size()` to report as fixed-size so that parent containers treat
it as an opaque byte blob. This avoids leaking internal structure (field
count, offset layout) into the wire format, keeping the signature scheme
an implementation detail that can evolve independently.
"""

path: HashTreeOpening
Expand All @@ -75,6 +78,23 @@ class Signature(Container):
hashes: HashDigestList
"""The one-time signature itself: a list of intermediate Winternitz chain hashes."""

@classmethod
@override
def is_fixed_size(cls) -> bool:
"""
Report as fixed-size for cross-client SSZ interoperability.

Ream serializes XMSS signatures as `FixedBytes<3112>`, so parent
containers must inline the bytes without an offset pointer.
"""
return True

@classmethod
@override
def get_byte_length(cls) -> int:
"""Return the fixed byte length of the SSZ-encoded signature."""
return TARGET_CONFIG.SIGNATURE_LEN_BYTES

@model_serializer(mode="plain", when_used="json")
def _serialize_as_bytes(self) -> str:
"""Serialize as hex-encoded SSZ bytes for JSON output."""
Expand Down
20 changes: 5 additions & 15 deletions tests/consensus/devnet/ssz/test_consensus_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import pytest
from consensus_testing import SSZTestFiller
from consensus_testing.keys import create_dummy_signature

from lean_spec.subspecs.containers import (
AggregatedAttestation,
Expand Down Expand Up @@ -33,7 +34,6 @@
JustifiedSlots,
Validators,
)
from lean_spec.subspecs.xmss import Signature
from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof
from lean_spec.types import Boolean, ByteListMiB, Bytes32, Bytes52, Uint64

Expand All @@ -59,16 +59,6 @@ def _typical_attestation_data() -> AttestationData:
return AttestationData(slot=Slot(100), head=head, target=target, source=source)


# Empty signature: path=[], rho=zeros, hashes=[]
EMPTY_SIGNATURE_BYTES = bytes.fromhex(
"24000000000000000000000000000000000000000000000000000000000000002800000004000000"
)


def _empty_signature() -> Signature:
return Signature.decode_bytes(EMPTY_SIGNATURE_BYTES)


# --- Checkpoint ---


Expand Down Expand Up @@ -127,7 +117,7 @@ def test_signed_attestation_minimal(ssz: SSZTestFiller) -> None:
value=SignedAttestation(
validator_id=ValidatorIndex(0),
data=_zero_attestation_data(),
signature=_empty_signature(),
signature=create_dummy_signature(),
),
)

Expand Down Expand Up @@ -286,7 +276,7 @@ def test_block_signatures_empty(ssz: SSZTestFiller) -> None:
type_name="BlockSignatures",
value=BlockSignatures(
attestation_signatures=AttestationSignatures(data=[]),
proposer_signature=_empty_signature(),
proposer_signature=create_dummy_signature(),
),
)

Expand All @@ -304,7 +294,7 @@ def test_block_signatures_with_attestation(ssz: SSZTestFiller) -> None:
)
]
),
proposer_signature=_empty_signature(),
proposer_signature=create_dummy_signature(),
),
)

Expand All @@ -325,7 +315,7 @@ def test_signed_block_with_attestation_minimal(ssz: SSZTestFiller) -> None:
message = BlockWithAttestation(block=block, proposer_attestation=attestation)
signature = BlockSignatures(
attestation_signatures=AttestationSignatures(data=[]),
proposer_signature=_empty_signature(),
proposer_signature=create_dummy_signature(),
)
ssz(
type_name="SignedBlockWithAttestation",
Expand Down
14 changes: 14 additions & 0 deletions tests/lean_spec/subspecs/xmss/test_cross_client_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from typing import override

import pytest

from lean_spec.subspecs.containers.slot import Slot
Expand Down Expand Up @@ -35,6 +37,18 @@ class ProdSignature(Container):
rho: Randomness
hashes: ProdHashDigestList

@classmethod
@override
def is_fixed_size(cls) -> bool:
"""Report as fixed-size, matching ream's `FixedBytes<3112>`."""
return True

@classmethod
@override
def get_byte_length(cls) -> int:
"""Return the prod signature byte length."""
return PROD_CONFIG.SIGNATURE_LEN_BYTES


REAM_PUBLIC_KEY_HEX = (
"7bbaf95bd653c827b5775e00b973b24d50ab4743db3373244f29c95fdf4ccc62"
Expand Down
Loading