diff --git a/src/lean_spec/subspecs/xmss/containers.py b/src/lean_spec/subspecs/xmss/containers.py index 32467d517..565f6bfa6 100644 --- a/src/lean_spec/subspecs/xmss/containers.py +++ b/src/lean_spec/subspecs/xmss/containers.py @@ -8,7 +8,7 @@ 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 @@ -16,6 +16,7 @@ from ...types import Bytes32, Uint64 from ...types.container import Container +from .constants import TARGET_CONFIG from .subtree import HashSubTree from .types import ( HashDigestList, @@ -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 @@ -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.""" diff --git a/tests/consensus/devnet/ssz/test_consensus_containers.py b/tests/consensus/devnet/ssz/test_consensus_containers.py index e18963998..0dc2a51f6 100644 --- a/tests/consensus/devnet/ssz/test_consensus_containers.py +++ b/tests/consensus/devnet/ssz/test_consensus_containers.py @@ -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, @@ -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 @@ -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 --- @@ -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(), ), ) @@ -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(), ), ) @@ -304,7 +294,7 @@ def test_block_signatures_with_attestation(ssz: SSZTestFiller) -> None: ) ] ), - proposer_signature=_empty_signature(), + proposer_signature=create_dummy_signature(), ), ) @@ -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", diff --git a/tests/lean_spec/subspecs/xmss/test_cross_client_compat.py b/tests/lean_spec/subspecs/xmss/test_cross_client_compat.py index 995c00db0..f65e7dc6f 100644 --- a/tests/lean_spec/subspecs/xmss/test_cross_client_compat.py +++ b/tests/lean_spec/subspecs/xmss/test_cross_client_compat.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import override + import pytest from lean_spec.subspecs.containers.slot import Slot @@ -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"