Skip to main content

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>(&current, &current);
147    }
148    current
149}