ssz/merkle.rs
1//! SSZ merkleization primitives.
2//!
3//! All hashes are 32-byte digests, threaded through the [`digest::Digest`]
4//! trait with `OutputSize = U32`. No domain bytes or prefixes are mixed in
5//! at the node level — concatenation is the only operation.
6
7use alloc::vec::Vec;
8use digest::Digest;
9use digest::typenum::U32;
10
11use crate::BYTES_PER_CHUNK;
12
13/// Hash two 32-byte children into their parent node.
14#[inline]
15pub fn hash_pair<D: Digest<OutputSize = U32>>(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
16 let mut hasher = D::new();
17 hasher.update(left);
18 hasher.update(right);
19 let out = hasher.finalize();
20 let mut arr = [0u8; 32];
21 arr.copy_from_slice(out.as_slice());
22 arr
23}
24
25/// Pack a byte slice into 32-byte chunks, zero-padding the tail.
26pub fn pack_bytes(bytes: &[u8]) -> Vec<[u8; 32]> {
27 if bytes.is_empty() {
28 return Vec::new();
29 }
30 let n = bytes.len().div_ceil(BYTES_PER_CHUNK);
31 let mut out = Vec::with_capacity(n);
32 let mut cursor = 0;
33 for _ in 0..n {
34 let mut chunk = [0u8; 32];
35 let take = core::cmp::min(BYTES_PER_CHUNK, bytes.len() - cursor);
36 chunk[..take].copy_from_slice(&bytes[cursor..cursor + take]);
37 out.push(chunk);
38 cursor += take;
39 }
40 out
41}
42
43/// Returns `ceil(log2(max(1, n)))`.
44#[inline]
45pub fn ceil_log2(n: u64) -> usize {
46 if n <= 1 {
47 return 0;
48 }
49 (64 - (n - 1).leading_zeros()) as usize
50}
51
52/// SSZ `merkleize` — pad `chunks` to `max(limit, chunks.len())` rounded up
53/// to the next power of two, build a balanced binary tree using
54/// `hash(left || right)`, and return the root.
55///
56/// `limit` is the type-level chunk cap (e.g. `ceil(N * size_of_T / 32)` for
57/// `List<T, N>`). When `chunks.len() > limit`, the limit is bumped up to
58/// `chunks.len()` (callers should validate the type-level cap separately).
59///
60/// Empty input with `limit == 0` returns a zero hash (the root of a single
61/// zero chunk).
62///
63/// Complexity: `O(chunks.len() + depth)` hash operations, independent of
64/// `limit`. This is achieved by only materialising the "real" prefix at
65/// each level; the implicit zero-padded suffix folds into `zero_hash(d)`
66/// without iteration.
67pub fn merkleize<D: Digest<OutputSize = U32>>(chunks: &[[u8; 32]], limit: usize) -> [u8; 32] {
68 let target = core::cmp::max(limit, chunks.len()).max(1);
69 let padded_len = target.next_power_of_two();
70 let depth = padded_len.trailing_zeros() as usize;
71
72 if padded_len == 1 {
73 return chunks.first().copied().unwrap_or([0u8; 32]);
74 }
75
76 // Empty input with depth > 0 → entire tree is implicit zero padding.
77 if chunks.is_empty() {
78 return zero_hash::<D>(depth);
79 }
80
81 // Precompute the zero-hash table for this call's `depth`. Each level's
82 // zero_hash is `H(prev || prev)`; computing it once up-front turns the
83 // per-level lookup from O(d) into O(1) and the total from O(depth²)
84 // into O(depth). Without this, a depth-32 merkleize (e.g. a Vec<T> with
85 // MAX_VEC_LEN = 1 << 32) burns ~496 redundant SHA-256s per call just
86 // recomputing the same zero hashes.
87 let mut zero_h_table: Vec<[u8; 32]> = Vec::with_capacity(depth);
88 let mut cur_zero = [0u8; 32];
89 zero_h_table.push(cur_zero);
90 for _ in 1..depth {
91 cur_zero = hash_pair::<D>(&cur_zero, &cur_zero);
92 zero_h_table.push(cur_zero);
93 }
94
95 // Iterative bottom-up reduction. At each level we only iterate over the
96 // "real" entries; missing right siblings draw from `zero_h_table[d]`. The
97 // implicit padding to `padded_len` is handled by continuing to fold for
98 // the full `depth` iterations even after `level.len()` reaches 1.
99 let mut level: Vec<[u8; 32]> = Vec::new();
100 level.extend_from_slice(chunks);
101
102 for &zero_h in zero_h_table.iter().take(depth) {
103 let next_count = level.len().div_ceil(2);
104 let mut next: Vec<[u8; 32]> = Vec::with_capacity(next_count);
105 for i in 0..next_count {
106 let l = level[2 * i];
107 let r = level.get(2 * i + 1).copied().unwrap_or(zero_h);
108 next.push(hash_pair::<D>(&l, &r));
109 }
110 level = next;
111 }
112
113 level[0]
114}
115
116/// `mix_in_length(root, len) = hash(root || u256_le(len))`.
117#[inline]
118pub fn mix_in_length<D: Digest<OutputSize = U32>>(root: [u8; 32], len: u64) -> [u8; 32] {
119 let mut buf = [0u8; 32];
120 buf[..8].copy_from_slice(&len.to_le_bytes());
121 hash_pair::<D>(&root, &buf)
122}
123
124/// `mix_in_selector(root, sel) = hash(root || u256_le(sel))`.
125///
126/// Note that the selector is padded to a full 32-byte little-endian u256,
127/// matching the spec (not just one byte).
128#[inline]
129pub fn mix_in_selector<D: Digest<OutputSize = U32>>(root: [u8; 32], selector: u8) -> [u8; 32] {
130 let mut buf = [0u8; 32];
131 buf[0] = selector;
132 hash_pair::<D>(&root, &buf)
133}
134
135/// Compute `zero_hash(depth)`, where
136/// `zero_hash(d) = hash(zero_hash(d-1), zero_hash(d-1))` and
137/// `zero_hash(0) == [0u8; 32]`. Recomputed per call (not memoized).
138pub fn zero_hash<D: Digest<OutputSize = U32>>(depth: usize) -> [u8; 32] {
139 // We rebuild the table per call. This is sufficient for jar's tree
140 // depths (≤ 64 in practice); a future optimisation could memoize a
141 // `OnceLock<[u8; 32]; 64>` per hash type, but no_std forbids
142 // unconditional `OnceLock`. Callers that hash hot paths should cache
143 // their own zero-hash array.
144 let mut current = [0u8; 32];
145 for _ in 0..depth {
146 current = hash_pair::<D>(¤t, ¤t);
147 }
148 current
149}