Skip to content

Commit 5d7c60a

Browse files
authored
subspec: add ssz subspec (leanEthereum#39)
* types: add Byte type to encapsulate Uint8 * subspec: add ssz subspec
1 parent 6e91dcd commit 5d7c60a

9 files changed

Lines changed: 671 additions & 6 deletions

File tree

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""Constants defined in the SSZ specification."""
22

3+
from lean_spec.types.hash import Bytes32
4+
35
BYTES_PER_CHUNK: int = 32
46
"""The number of bytes in a Merkle tree chunk."""
57

6-
ZERO_HASH: bytes = b"\x00" * BYTES_PER_CHUNK
8+
ZERO_HASH: Bytes32 = b"\x00" * BYTES_PER_CHUNK
79
"""A zero hash, used for padding in the Merkle tree."""

src/lean_spec/subspecs/ssz/gindex.py

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

33
from typing import List
44

5-
from pydantic import BaseModel, Field
5+
from pydantic import BaseModel, ConfigDict, Field
66

77

88
class GeneralizedIndex(BaseModel):
@@ -12,8 +12,14 @@ class GeneralizedIndex(BaseModel):
1212
Helper methods are provided for tree navigation.
1313
"""
1414

15+
model_config = ConfigDict(strict=True)
16+
1517
value: int = Field(..., gt=0, description="The index value, must be a positive integer.")
1618

19+
def __hash__(self) -> int:
20+
"""Hashes the index value."""
21+
return hash(self.value)
22+
1723
@property
1824
def depth(self) -> int:
1925
"""The depth of the node in the tree."""
@@ -35,12 +41,26 @@ def parent(self) -> "GeneralizedIndex":
3541
raise ValueError("Root node has no parent.")
3642
return type(self)(value=self.value // 2)
3743

44+
def child(self, right_side: bool) -> "GeneralizedIndex":
45+
"""
46+
Returns the index of a child node.
47+
48+
- `right_side=False` for the left child (2k),
49+
- `right_side=True` for the right child (2k+1).
50+
"""
51+
return type(self)(value=self.value * 2 + int(right_side))
52+
3853
def get_branch_indices(self) -> List["GeneralizedIndex"]:
3954
"""Gets the indices of the sibling nodes along the path to the root."""
40-
indices = [self.sibling]
41-
while indices[-1].value > 1:
42-
indices.append(indices[-1].parent.sibling)
43-
return indices[:-1]
55+
indices: List["GeneralizedIndex"] = []
56+
current_index = self.value
57+
while current_index > 1:
58+
# Sibling is the current index XOR 1
59+
sibling_index = current_index ^ 1
60+
indices.append(type(self)(value=sibling_index))
61+
# Move up to the parent
62+
current_index //= 2
63+
return indices
4464

4565
def get_path_indices(self) -> List["GeneralizedIndex"]:
4666
"""Gets the indices of the nodes along the path to the root."""
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""SSZ Merkle related functionality."""
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Merkle proofs for SSZ."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Dict, List, Sequence, Set
6+
7+
from pydantic import Field, model_validator
8+
9+
from lean_spec.types.base import StrictBaseModel
10+
from lean_spec.types.hash import Bytes32
11+
12+
from ..constants import ZERO_HASH
13+
from ..gindex import GeneralizedIndex
14+
from ..utils import hash_nodes
15+
16+
Root = bytes
17+
"""The type of a Merkle tree root."""
18+
Proof = Sequence[Bytes32]
19+
"""The type of a Merkle proof."""
20+
ProofHashes = Sequence[Bytes32]
21+
"""The type of a Merkle proof's helper nodes."""
22+
23+
24+
class MerkleProof(StrictBaseModel):
25+
"""
26+
Represents a Merkle multiproof, encapsulating its data and verification logic.
27+
28+
This object is immutable; once created, its contents cannot be changed.
29+
"""
30+
31+
leaves: Sequence[Bytes32] = Field(..., description="The leaf data being proven.")
32+
33+
indices: Sequence[GeneralizedIndex] = Field(
34+
..., description="The generalized indices of the leaves."
35+
)
36+
37+
proof_hashes: ProofHashes = Field(..., description="The helper nodes required for the proof.")
38+
39+
@model_validator(mode="after")
40+
def check_leaves_and_indices_length(self) -> MerkleProof:
41+
"""Ensures the number of leaves matches the number of indices."""
42+
if len(self.leaves) != len(self.indices):
43+
raise ValueError("The number of leaves must match the number of indices.")
44+
return self
45+
46+
@classmethod
47+
def from_single_leaf(
48+
cls, leaf: Bytes32, proof_hashes: ProofHashes, index: GeneralizedIndex
49+
) -> MerkleProof:
50+
"""Creates a MerkleProof object from a traditional single-item proof."""
51+
return cls(leaves=[leaf], proof_hashes=proof_hashes, indices=[index])
52+
53+
def _get_helper_indices(self) -> List[GeneralizedIndex]:
54+
"""
55+
Calculates the generalized indices of all "helper" nodes needed to prove the leaves.
56+
57+
This is an internal helper method.
58+
"""
59+
all_helper_indices: Set[GeneralizedIndex] = set()
60+
all_path_indices: Set[GeneralizedIndex] = set()
61+
62+
for index in self.indices:
63+
all_helper_indices.update(index.get_branch_indices())
64+
all_path_indices.update(index.get_path_indices())
65+
66+
return sorted(all_helper_indices - all_path_indices, key=lambda g: g.value, reverse=True)
67+
68+
def calculate_root(self) -> Root:
69+
"""
70+
Calculates the Merkle root from the proof's leaves and helper nodes.
71+
72+
Handles both single and multi-leaf proofs seamlessly.
73+
"""
74+
# For a single leaf proof, use the more direct calculation.
75+
if len(self.indices) == 1:
76+
index = self.indices[0]
77+
leaf = self.leaves[0]
78+
if len(self.proof_hashes) != index.depth:
79+
raise ValueError("Proof length must match the depth of the index.")
80+
81+
root = leaf
82+
for i, branch_node in enumerate(self.proof_hashes):
83+
if index.get_bit(i):
84+
root = hash_nodes(branch_node, root)
85+
else:
86+
root = hash_nodes(root, branch_node)
87+
return root
88+
89+
# For multi-leaf proofs, perform tree reconstruction.
90+
helper_indices = self._get_helper_indices()
91+
if len(self.proof_hashes) != len(helper_indices):
92+
raise ValueError("Proof length does not match the required number of helper nodes.")
93+
94+
# 1. Start with the known nodes (leaves and proof hashes).
95+
tree: Dict[int, Bytes32] = {
96+
**{index.value: node for index, node in zip(self.indices, self.leaves, strict=False)},
97+
**{
98+
index.value: node
99+
for index, node in zip(helper_indices, self.proof_hashes, strict=False)
100+
},
101+
}
102+
103+
# 2. Process nodes from deepest to shallowest.
104+
# The list of keys will grow as we create new parent nodes.
105+
keys = sorted(tree.keys(), reverse=True)
106+
pos = 0
107+
while pos < len(keys):
108+
key = keys[pos]
109+
sibling_key = key ^ 1
110+
111+
# 3. If a node's sibling is also in the tree, we can create their parent.
112+
if sibling_key in tree:
113+
parent_key = key // 2
114+
115+
# Ensure we don't re-calculate a parent we already have.
116+
if parent_key not in tree:
117+
# The order of hashing depends on which key is smaller.
118+
if key < sibling_key:
119+
tree[parent_key] = hash_nodes(tree[key], tree[sibling_key])
120+
else:
121+
tree[parent_key] = hash_nodes(tree[sibling_key], tree[key])
122+
keys.append(parent_key)
123+
pos += 1
124+
125+
# 4. After processing all nodes, the root must be at index 1.
126+
if 1 not in tree:
127+
# This can happen if the proof is incomplete or for an empty leaf set.
128+
return ZERO_HASH
129+
130+
return tree[1]
131+
132+
def verify(self, root: Root) -> bool:
133+
"""Verifies the Merkle proof against a known root."""
134+
try:
135+
return self.calculate_root() == root
136+
except ValueError:
137+
return False
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Merkle tree building logic."""
2+
3+
from typing import List, Sequence
4+
5+
from lean_spec.subspecs.ssz.constants import ZERO_HASH
6+
from lean_spec.subspecs.ssz.utils import get_power_of_two_ceil, hash_nodes
7+
from lean_spec.types.hash import Bytes32
8+
9+
10+
def build_merkle_tree(leaves: Sequence[Bytes32]) -> List[Bytes32]:
11+
r"""
12+
Builds a full Merkle tree and returns it as a flat list.
13+
14+
The tree is represented as a list where the node at a generalized
15+
index `i` is located at `tree[i]`. The 0-index is a placeholder.
16+
"""
17+
# Handle the edge case of no leaves.
18+
if not leaves:
19+
# Per the spec, a tree of an empty list is a single ZERO_HASH.
20+
#
21+
# The flat list format includes a placeholder at index 0.
22+
return [ZERO_HASH] * 2
23+
24+
# Calculate the required size of the bottom layer (must be a power of two).
25+
bottom_layer_size = get_power_of_two_ceil(len(leaves))
26+
27+
# Create the complete, padded leaf layer.
28+
padded_leaves = list(leaves) + [ZERO_HASH] * (bottom_layer_size - len(leaves))
29+
30+
# Initialize the tree with placeholders for parent nodes and the padded leaves.
31+
#
32+
# The first half of the list will store the calculated parent nodes.
33+
tree = [ZERO_HASH] * bottom_layer_size + padded_leaves
34+
35+
# Iterate backwards from the last parent node up to the root.
36+
#
37+
# This calculates the tree from the bottom up.
38+
for i in range(bottom_layer_size - 1, 0, -1):
39+
# A parent at index `i` is the hash of its two children at `2*i` and `2*i+1`.
40+
tree[i] = hash_nodes(tree[i * 2], tree[i * 2 + 1])
41+
42+
# Return the fully constructed tree.
43+
return tree
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Generic helper functions for SSZ and Merkle proofs."""
2+
3+
import hashlib
4+
5+
from lean_spec.types.hash import Bytes32
6+
7+
8+
def get_power_of_two_ceil(x: int) -> int:
9+
"""
10+
Calculates the smallest power of two greater than or equal to x.
11+
12+
Examples: 0->1, 1->1, 2->2, 3->4, 4->4, 5->8.
13+
"""
14+
if x <= 1:
15+
return 1
16+
return 1 << (x - 1).bit_length()
17+
18+
19+
def hash_nodes(node_a: Bytes32, node_b: Bytes32) -> Bytes32:
20+
"""Hashes two 32-byte nodes together using SHA-256."""
21+
return hashlib.sha256(node_a + node_b).digest()

0 commit comments

Comments
 (0)