Skip to content

Commit b400e73

Browse files
authored
xmss: some type simplifications (leanEthereum#206)
1 parent 0dc3c6d commit b400e73

5 files changed

Lines changed: 40 additions & 40 deletions

File tree

src/lean_spec/subspecs/xmss/containers.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from typing import TYPE_CHECKING, Annotated, List
5+
from typing import TYPE_CHECKING, Annotated, List, cast
66

77
from pydantic import Field
88

@@ -179,7 +179,7 @@ class PublicKey(StrictBaseModel):
179179
All field elements are serialized in little-endian byte order.
180180
"""
181181

182-
root: List[Fp]
182+
root: HashDigestVector
183183
"""The Merkle root, which commits to all one-time keys for the key's lifetime."""
184184
parameter: Parameter
185185
"""The public parameter `P` that personalizes the hash function."""
@@ -196,7 +196,9 @@ def __bytes__(self) -> bytes:
196196
>>> isinstance(data, bytes)
197197
True
198198
"""
199-
return Fp.serialize_list(self.root) + Fp.serialize_list(self.parameter)
199+
return Fp.serialize_list(cast(List[Fp], list(self.root.data))) + Fp.serialize_list(
200+
self.parameter
201+
)
200202

201203
def to_bytes(self, config: XmssConfig) -> bytes:
202204
"""
@@ -262,7 +264,7 @@ def from_bytes(cls, data: bytes, config: XmssConfig) -> PublicKey:
262264
root = Fp.deserialize_list(data[:root_len], config.HASH_LEN_FE)
263265
parameter = Fp.deserialize_list(data[root_len:], config.PARAMETER_LEN)
264266

265-
return cls(root=root, parameter=parameter)
267+
return cls(root=HashDigestVector(data=root), parameter=parameter)
266268

267269

268270
class Signature(StrictBaseModel):

src/lean_spec/subspecs/xmss/interface.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ def key_gen(
191191
)
192192

193193
# Collect roots for building the top tree.
194-
bottom_tree_roots: List[List[Fp]] = [
194+
bottom_tree_roots: List[HashDigestVector] = [
195195
left_bottom_tree.root(),
196196
right_bottom_tree.root(),
197197
]

src/lean_spec/subspecs/xmss/merkle_tree.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -168,17 +168,15 @@ def build(
168168
# A full tree is represented as a HashSubTree with lowest_layer=0
169169
return HashSubTree(depth=Uint64(depth), lowest_layer=Uint64(0), layers=layers)
170170

171-
def root(self, tree: HashSubTree) -> List[Fp]:
171+
def root(self, tree: HashSubTree) -> HashDigestVector:
172172
"""
173173
Extracts the root digest from a constructed Merkle tree.
174174
175175
The root is the single node in the final, highest layer of the `HashSubTree`
176176
and serves as the primary component of the master public key.
177177
"""
178178
# The root is the single node in the final layer.
179-
root_node = cast(HashDigestVector, tree.layers[-1].nodes.data[0])
180-
root_data = cast("Tuple[Fp, ...]", root_node.data)
181-
return list(root_data)
179+
return cast(HashDigestVector, tree.layers[-1].nodes[0])
182180

183181
def path(self, tree: HashSubTree, position: Uint64) -> HashTreeOpening:
184182
"""
@@ -236,7 +234,7 @@ def path(self, tree: HashSubTree, position: Uint64) -> HashTreeOpening:
236234
def verify_path(
237235
self,
238236
parameter: Parameter,
239-
root: List[Fp],
237+
root: HashDigestVector,
240238
position: Uint64,
241239
leaf_parts: List[List[Fp]],
242240
opening: HashTreeOpening,
@@ -316,7 +314,7 @@ def verify_path(
316314

317315
# After iterating through the entire path, the final computed node
318316
# should be the root of the tree.
319-
return current_node == root
317+
return current_node == list(root.data)
320318

321319

322320
PROD_MERKLE_TREE = MerkleTree(config=PROD_CONFIG, hasher=PROD_TWEAK_HASHER, rand=PROD_RAND)

src/lean_spec/subspecs/xmss/subtree.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ def new_top_tree(
189189
depth: int,
190190
start_bottom_tree_index: Uint64,
191191
parameter: Parameter,
192-
bottom_tree_roots: List[List[Fp]],
192+
bottom_tree_roots: List[HashDigestVector],
193193
) -> HashSubTree:
194194
"""
195195
Constructs a top tree from the roots of bottom trees.
@@ -232,6 +232,9 @@ def new_top_tree(
232232
# The top tree starts at the middle layer.
233233
lowest_layer = depth // 2
234234

235+
# Convert HashDigestVector roots to List[Fp] for building
236+
roots_as_lists = [cast(List[Fp], list(root.data)) for root in bottom_tree_roots]
237+
235238
# Build the top tree using the bottom tree roots as the lowest layer.
236239
return cls.new(
237240
hasher=hasher,
@@ -240,7 +243,7 @@ def new_top_tree(
240243
depth=depth,
241244
start_index=start_bottom_tree_index,
242245
parameter=parameter,
243-
lowest_layer_nodes=bottom_tree_roots,
246+
lowest_layer_nodes=roots_as_lists,
244247
)
245248

246249
@classmethod
@@ -344,7 +347,7 @@ def new_bottom_tree(
344347

345348
return cls(depth=Uint64(depth), lowest_layer=Uint64(0), layers=truncated_layers)
346349

347-
def root(self) -> List[Fp]:
350+
def root(self) -> HashDigestVector:
348351
"""
349352
Extracts the root digest from this subtree.
350353
@@ -366,9 +369,7 @@ def root(self) -> List[Fp]:
366369

367370
# The root is the only node in the highest layer for proper subtrees.
368371
# For top trees and bottom trees, the highest layer should have exactly one node.
369-
root_node = cast(HashDigestVector, highest_layer.nodes.data[0])
370-
root_data = cast("Tuple[Fp, ...]", root_node.data)
371-
return list(root_data)
372+
return cast(HashDigestVector, highest_layer.nodes[0])
372373

373374
def path(self, position: Uint64) -> HashTreeOpening:
374375
"""
@@ -400,7 +401,8 @@ def path(self, position: Uint64) -> HashTreeOpening:
400401
if position >= lowest_layer.start_index + Uint64(len(lowest_layer.nodes)):
401402
raise ValueError("Position is beyond the subtree's range.")
402403

403-
co_path: List[List[Fp]] = []
404+
# Build the co-path directly with SSZ types
405+
siblings = HashDigestList(data=[])
404406
current_position = position
405407

406408
# Iterate through layers from lowest to highest, EXCLUDING the final root layer.
@@ -412,23 +414,20 @@ def path(self, position: Uint64) -> HashTreeOpening:
412414
sibling_index = sibling_position - layer.start_index
413415

414416
# Ensure the sibling exists in this layer
415-
if sibling_index < Uint64(0) or sibling_index >= Uint64(len(layer.nodes.data)):
417+
if sibling_index < Uint64(0) or sibling_index >= Uint64(len(layer.nodes)):
416418
raise ValueError(
417419
f"Sibling index {sibling_index} out of bounds for layer "
418-
f"with {len(layer.nodes.data)} nodes"
420+
f"with {len(layer.nodes)} nodes"
419421
)
420422

421-
# Add the sibling's hash to the co-path.
422-
sibling_node = cast(HashDigestVector, layer.nodes.data[int(sibling_index)])
423-
sibling_data = cast("Tuple[Fp, ...]", sibling_node.data)
424-
co_path.append(list(sibling_data))
423+
# Access the sibling directly from the SSZ list and add to path
424+
siblings = siblings + [layer.nodes[int(sibling_index)]]
425425

426426
# Move to the parent's position for the next iteration.
427427
current_position = current_position // Uint64(2)
428428

429-
# Wrap in SSZ types
430-
ssz_siblings = [HashDigestVector(data=sibling) for sibling in co_path]
431-
return HashTreeOpening(siblings=HashDigestList(data=ssz_siblings))
429+
# Return the opening with SSZ-typed siblings
430+
return HashTreeOpening(siblings=siblings)
432431

433432

434433
def combined_path(

tests/lean_spec/subspecs/xmss/test_containers.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class TestPublicKey:
2020

2121
def test_bytes_protocol(self) -> None:
2222
"""Test that PublicKey implements Python's bytes protocol."""
23-
root = [Fp(value=i) for i in range(TEST_CONFIG.HASH_LEN_FE)]
23+
root = HashDigestVector(data=[Fp(value=i) for i in range(TEST_CONFIG.HASH_LEN_FE)])
2424
parameter = [Fp(value=i + 100) for i in range(TEST_CONFIG.PARAMETER_LEN)]
2525
pk = PublicKey(root=root, parameter=parameter)
2626

@@ -31,18 +31,17 @@ def test_bytes_protocol(self) -> None:
3131

3232
def test_to_bytes_with_validation(self) -> None:
3333
"""Test that to_bytes validates field lengths."""
34-
root = [Fp(value=i) for i in range(TEST_CONFIG.HASH_LEN_FE)]
34+
root = HashDigestVector(data=[Fp(value=i) for i in range(TEST_CONFIG.HASH_LEN_FE)])
3535
parameter = [Fp(value=i) for i in range(TEST_CONFIG.PARAMETER_LEN)]
3636
pk = PublicKey(root=root, parameter=parameter)
3737

3838
# Valid serialization
3939
data = pk.to_bytes(TEST_CONFIG)
4040
assert len(data) == TEST_CONFIG.PUBLIC_KEY_LEN_BYTES
4141

42-
# Invalid root length
43-
invalid_pk = PublicKey(root=[Fp(value=0)] * 5, parameter=parameter)
44-
with pytest.raises(ValueError, match="Invalid root length"):
45-
invalid_pk.to_bytes(TEST_CONFIG)
42+
# Invalid root length - HashDigestVector validates length at construction
43+
with pytest.raises(ValueError, match="requires exactly"):
44+
HashDigestVector(data=[Fp(value=0)] * 5)
4645

4746
# Invalid parameter length
4847
invalid_pk = PublicKey(root=root, parameter=[Fp(value=0)] * 3)
@@ -51,7 +50,7 @@ def test_to_bytes_with_validation(self) -> None:
5150

5251
def test_roundtrip_test_config(self) -> None:
5352
"""Test serialization round-trip with TEST_CONFIG."""
54-
root = [Fp(value=i * 10) for i in range(TEST_CONFIG.HASH_LEN_FE)]
53+
root = HashDigestVector(data=[Fp(value=i * 10) for i in range(TEST_CONFIG.HASH_LEN_FE)])
5554
parameter = [Fp(value=i * 20) for i in range(TEST_CONFIG.PARAMETER_LEN)]
5655
original = PublicKey(root=root, parameter=parameter)
5756

@@ -65,7 +64,7 @@ def test_roundtrip_test_config(self) -> None:
6564

6665
def test_roundtrip_prod_config(self) -> None:
6766
"""Test serialization round-trip with PROD_CONFIG."""
68-
root = [Fp(value=i) for i in range(PROD_CONFIG.HASH_LEN_FE)]
67+
root = HashDigestVector(data=[Fp(value=i) for i in range(PROD_CONFIG.HASH_LEN_FE)])
6968
parameter = [Fp(value=i + 1000) for i in range(PROD_CONFIG.PARAMETER_LEN)]
7069
original = PublicKey(root=root, parameter=parameter)
7170

@@ -86,14 +85,16 @@ def test_from_bytes_invalid_length(self) -> None:
8685

8786
def test_serialization_format(self) -> None:
8887
"""Test that serialization follows the documented format: root || parameter."""
89-
root = [Fp(value=i) for i in range(TEST_CONFIG.HASH_LEN_FE)]
88+
root = HashDigestVector(data=[Fp(value=i) for i in range(TEST_CONFIG.HASH_LEN_FE)])
9089
parameter = [Fp(value=i + 100) for i in range(TEST_CONFIG.PARAMETER_LEN)]
9190
pk = PublicKey(root=root, parameter=parameter)
9291

9392
data = bytes(pk)
9493

9594
# Check that root comes first
96-
root_data = Fp.serialize_list(root)
95+
from typing import List, cast
96+
97+
root_data = Fp.serialize_list(cast(List[Fp], list(root.data)))
9798
parameter_data = Fp.serialize_list(parameter)
9899

99100
assert data == root_data + parameter_data
@@ -253,7 +254,7 @@ class TestSerializationProperties:
253254

254255
def test_public_key_deterministic(self) -> None:
255256
"""Test that serialization is deterministic."""
256-
root = [Fp(value=i) for i in range(TEST_CONFIG.HASH_LEN_FE)]
257+
root = HashDigestVector(data=[Fp(value=i) for i in range(TEST_CONFIG.HASH_LEN_FE)])
257258
parameter = [Fp(value=i) for i in range(TEST_CONFIG.PARAMETER_LEN)]
258259
pk = PublicKey(root=root, parameter=parameter)
259260

@@ -288,8 +289,8 @@ def test_signature_deterministic(self) -> None:
288289

289290
def test_different_values_produce_different_bytes(self) -> None:
290291
"""Test that different values produce different serializations."""
291-
root1 = [Fp(value=i) for i in range(TEST_CONFIG.HASH_LEN_FE)]
292-
root2 = [Fp(value=i + 1) for i in range(TEST_CONFIG.HASH_LEN_FE)]
292+
root1 = HashDigestVector(data=[Fp(value=i) for i in range(TEST_CONFIG.HASH_LEN_FE)])
293+
root2 = HashDigestVector(data=[Fp(value=i + 1) for i in range(TEST_CONFIG.HASH_LEN_FE)])
293294
parameter = [Fp(value=0)] * TEST_CONFIG.PARAMETER_LEN
294295

295296
pk1 = PublicKey(root=root1, parameter=parameter)

0 commit comments

Comments
 (0)