From 3f490ea6775326e125389c858fd1ddb955f98384 Mon Sep 17 00:00:00 2001 From: krushimir <189111540+krushimir@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:44:17 +0200 Subject: [PATCH 01/10] feat(mmr): prototype MMB --- CHANGELOG.md | 1 + miden-crypto/Cargo.toml | 5 + miden-crypto/benches/mmr_belt.rs | 374 ++++++++ miden-crypto/src/merkle/mmr/belt.rs | 1369 +++++++++++++++++++++++++++ miden-crypto/src/merkle/mmr/mod.rs | 4 + 5 files changed, 1753 insertions(+) create mode 100644 miden-crypto/benches/mmr_belt.rs create mode 100644 miden-crypto/src/merkle/mmr/belt.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c62340b150..d1e6a9dc32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - [BREAKING] Extracted `BackendReader`, allowing `LargeSmtForest` to work with read-only storage backends ([#986](https://github.com/0xMiden/crypto/pull/986)). - [BREAKING] Refactored `miden-lifted-stark::domain` around a uniform `Coset` trait shared by `TwoAdicSubgroup` and `TwoAdicCoset`, slimmed the `LiftedDomain` surface (drops dead getters, removes silently-dispatched `points`/`bit_reversed_points`/`vanishing_at` in favour of explicit `trace_subgroup()` / `lde_coset()` access), made `LiftedDomain` constructors fallible, moved selector logic onto `LiftedDomain`, and changed `log_blowup` to return `u8` ([#993](https://github.com/0xMiden/crypto/pull/993)). - Added `MerkleFrontier` as append-only `len + peaks` state with a raw Merkle root, append support, legacy `MmrPeaks` conversion, and standard Merkle proof bridging that authenticates `(len, root)` during verification ([#984](https://github.com/0xMiden/crypto/pull/984)). +- Added internal Merkle Mountain Belt prototype benchmarks for evaluating append, opening, summary, and recency-sensitive proof verification tradeoffs ([#1028](https://github.com/0xMiden/crypto/pull/1028)). ## 0.25.0 (2026-05-01) diff --git a/miden-crypto/Cargo.toml b/miden-crypto/Cargo.toml index b3935bfd87..36012ca6da 100644 --- a/miden-crypto/Cargo.toml +++ b/miden-crypto/Cargo.toml @@ -44,6 +44,11 @@ harness = false name = "mmr" required-features = ["std"] +[[bench]] +harness = false +name = "mmr_belt" +required-features = ["internal"] + [[bench]] harness = false name = "partial_mt" diff --git a/miden-crypto/benches/mmr_belt.rs b/miden-crypto/benches/mmr_belt.rs new file mode 100644 index 0000000000..c7b9ad044c --- /dev/null +++ b/miden-crypto/benches/mmr_belt.rs @@ -0,0 +1,374 @@ +//! MMR vs experimental Merkle Mountain Belt benchmarks. +//! +//! The belt implementation benchmarked here is a reference prototype behind the `internal` +//! feature. It keeps enough in-memory tree state to derive summaries/proofs, so these numbers +//! should be read as construction-comparison data rather than production storage performance. + +use std::{hint, time::Duration}; + +use criterion::{BatchSize, BenchmarkId, Criterion, criterion_group, criterion_main}; +use miden_crypto::{ + Word, + merkle::mmr::{ + Mmr, MmrPeaks, + belt::{BeltSummary, MmrBelt}, + }, +}; + +mod common; +use common::data::{WordPattern, generate_word_pattern, generate_words_pattern}; + +const MMR_BELT_SIZES: &[usize] = &[1_000, 1_023, 1_024, 50_000, 65_535, 65_536]; +const RECENCIES: &[usize] = &[1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1_024, 4_096, 16_384]; +const MEASUREMENT_TIME: Duration = Duration::from_secs(2); +const SAMPLE_SIZE: usize = 20; + +#[derive(Clone)] +struct MmrBeltBenchData { + leaves: Vec, + mmr: Mmr, + peaks: MmrPeaks, + belt: MmrBelt, + belt_summary: BeltSummary, +} + +impl MmrBeltBenchData { + fn build(size: usize) -> Self { + let leaves = generate_words_pattern(size, WordPattern::Sequential); + let mmr = Mmr::try_from_iter(leaves.iter().copied()).unwrap(); + let peaks = mmr.peaks(); + + let mut belt = MmrBelt::new(); + for leaf in leaves.iter().copied() { + belt.add(leaf).unwrap(); + } + let belt_summary = belt.summary(); + + Self { leaves, mmr, peaks, belt, belt_summary } + } +} + +fn bench_mmr_belt_build(c: &mut Criterion) { + let mut group = c.benchmark_group("mmr-belt-build"); + configure_group(&mut group); + + for &size in MMR_BELT_SIZES { + group.bench_with_input(BenchmarkId::new("current-mmr", size), &size, |b, &size| { + b.iter_batched( + || generate_words_pattern(size, WordPattern::Sequential), + |leaves| { + hint::black_box(Mmr::try_from_iter(leaves).unwrap()); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_with_input(BenchmarkId::new("belt-prototype", size), &size, |b, &size| { + b.iter_batched( + || generate_words_pattern(size, WordPattern::Sequential), + |leaves| { + let mut belt = MmrBelt::new(); + for leaf in leaves { + belt.add(leaf).unwrap(); + } + hint::black_box(belt); + }, + BatchSize::SmallInput, + ); + }); + } + + group.finish(); +} + +fn bench_mmr_belt_append(c: &mut Criterion) { + let mut group = c.benchmark_group("mmr-belt-append"); + configure_group(&mut group); + + for &size in MMR_BELT_SIZES { + let data = MmrBeltBenchData::build(size); + let next_leaf = generate_word_pattern(size as u64, WordPattern::Sequential); + + group.bench_with_input(BenchmarkId::new("current-mmr", size), &size, |b, _| { + b.iter_batched( + || data.mmr.clone(), + |mut mmr| { + mmr.add(hint::black_box(next_leaf)).unwrap(); + hint::black_box(mmr); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_with_input(BenchmarkId::new("belt-prototype", size), &size, |b, _| { + b.iter_batched( + || data.belt.clone(), + |mut belt| { + belt.add(hint::black_box(next_leaf)).unwrap(); + hint::black_box(belt); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_with_input( + BenchmarkId::new("current-mmr-and-commitment", size), + &size, + |b, _| { + b.iter_batched( + || data.mmr.clone(), + |mut mmr| { + mmr.add(hint::black_box(next_leaf)).unwrap(); + hint::black_box(mmr.peaks().hash_peaks()); + }, + BatchSize::SmallInput, + ); + }, + ); + + group.bench_with_input( + BenchmarkId::new("belt-prototype-and-summary", size), + &size, + |b, _| { + b.iter_batched( + || data.belt.clone(), + |mut belt| { + belt.add(hint::black_box(next_leaf)).unwrap(); + hint::black_box(belt.summary()); + }, + BatchSize::SmallInput, + ); + }, + ); + } + + group.finish(); +} + +fn bench_mmr_belt_commitment(c: &mut Criterion) { + let mut group = c.benchmark_group("mmr-belt-commitment"); + configure_group(&mut group); + + for &size in MMR_BELT_SIZES { + let data = MmrBeltBenchData::build(size); + + group.bench_with_input(BenchmarkId::new("current-mmr-hash-peaks", size), &size, |b, _| { + b.iter(|| { + hint::black_box(data.peaks.hash_peaks()); + }); + }); + + group.bench_with_input(BenchmarkId::new("belt-summary", size), &size, |b, _| { + b.iter(|| { + hint::black_box(data.belt.summary()); + }); + }); + } + + group.finish(); +} + +fn bench_mmr_belt_open(c: &mut Criterion) { + let mut group = c.benchmark_group("mmr-belt-open"); + configure_group(&mut group); + + for &size in MMR_BELT_SIZES { + let data = MmrBeltBenchData::build(size); + + group.bench_with_input(BenchmarkId::new("current-mmr-oldest", size), &size, |b, _| { + b.iter(|| { + hint::black_box(data.mmr.open(0).unwrap()); + }); + }); + + group.bench_with_input(BenchmarkId::new("belt-oldest", size), &size, |b, _| { + b.iter(|| { + hint::black_box(data.belt.open(0).unwrap()); + }); + }); + + group.bench_with_input(BenchmarkId::new("current-mmr-newest", size), &size, |b, _| { + b.iter(|| { + hint::black_box(data.mmr.open(size - 1).unwrap()); + }); + }); + + group.bench_with_input(BenchmarkId::new("belt-newest", size), &size, |b, _| { + b.iter(|| { + hint::black_box(data.belt.open(size - 1).unwrap()); + }); + }); + } + + group.finish(); +} + +fn bench_mmr_belt_verify(c: &mut Criterion) { + let mut group = c.benchmark_group("mmr-belt-verify"); + configure_group(&mut group); + + for &size in MMR_BELT_SIZES { + let data = MmrBeltBenchData::build(size); + let oldest_mmr_proof = data.mmr.open(0).unwrap(); + let newest_mmr_proof = data.mmr.open(size - 1).unwrap(); + let oldest_belt_proof = data.belt.open(0).unwrap(); + let newest_belt_proof = data.belt.open(size - 1).unwrap(); + + group.bench_with_input(BenchmarkId::new("current-mmr-oldest", size), &size, |b, _| { + b.iter(|| { + data.peaks + .verify( + hint::black_box(data.leaves[0]), + hint::black_box(oldest_mmr_proof.clone()), + ) + .unwrap(); + }); + }); + + group.bench_with_input( + BenchmarkId::new("current-mmr-committed-oldest", size), + &size, + |b, _| { + let commitment = data.peaks.hash_peaks(); + b.iter(|| { + assert_eq!(hint::black_box(data.peaks.hash_peaks()), commitment); + data.peaks + .verify( + hint::black_box(data.leaves[0]), + hint::black_box(oldest_mmr_proof.clone()), + ) + .unwrap(); + }); + }, + ); + + group.bench_with_input(BenchmarkId::new("belt-oldest", size), &size, |b, _| { + b.iter(|| { + assert!( + hint::black_box(&oldest_belt_proof).verify(hint::black_box(&data.belt_summary)) + ); + }); + }); + + group.bench_with_input(BenchmarkId::new("current-mmr-newest", size), &size, |b, _| { + b.iter(|| { + data.peaks + .verify( + hint::black_box(data.leaves[size - 1]), + hint::black_box(newest_mmr_proof.clone()), + ) + .unwrap(); + }); + }); + + group.bench_with_input( + BenchmarkId::new("current-mmr-committed-newest", size), + &size, + |b, _| { + let commitment = data.peaks.hash_peaks(); + b.iter(|| { + assert_eq!(hint::black_box(data.peaks.hash_peaks()), commitment); + data.peaks + .verify( + hint::black_box(data.leaves[size - 1]), + hint::black_box(newest_mmr_proof.clone()), + ) + .unwrap(); + }); + }, + ); + + group.bench_with_input(BenchmarkId::new("belt-newest", size), &size, |b, _| { + b.iter(|| { + assert!( + hint::black_box(&newest_belt_proof).verify(hint::black_box(&data.belt_summary)) + ); + }); + }); + } + + group.finish(); +} + +fn bench_mmr_belt_recency(c: &mut Criterion) { + let mut group = c.benchmark_group("mmr-belt-recency"); + configure_group(&mut group); + + for &size in &[50_000, 65_536] { + let data = MmrBeltBenchData::build(size); + + for &recency in RECENCIES { + if recency > size { + continue; + } + let position = size - recency; + let mmr_proof = data.mmr.open(position).unwrap(); + let belt_proof = data.belt.open(position).unwrap(); + + group.bench_with_input( + BenchmarkId::new("current-mmr-verify", format!("{size}/{recency}")), + &(size, recency), + |b, _| { + b.iter(|| { + data.peaks + .verify( + hint::black_box(data.leaves[position]), + hint::black_box(mmr_proof.clone()), + ) + .unwrap(); + }); + }, + ); + + let commitment = data.peaks.hash_peaks(); + group.bench_with_input( + BenchmarkId::new("current-mmr-committed-verify", format!("{size}/{recency}")), + &(size, recency), + |b, _| { + b.iter(|| { + assert_eq!(hint::black_box(data.peaks.hash_peaks()), commitment); + data.peaks + .verify( + hint::black_box(data.leaves[position]), + hint::black_box(mmr_proof.clone()), + ) + .unwrap(); + }); + }, + ); + + group.bench_with_input( + BenchmarkId::new("belt-verify", format!("{size}/{recency}")), + &(size, recency), + |b, _| { + b.iter(|| { + assert!( + hint::black_box(&belt_proof) + .verify(hint::black_box(&data.belt_summary)) + ); + }); + }, + ); + } + } + + group.finish(); +} + +fn configure_group( + group: &mut criterion::BenchmarkGroup<'_, M>, +) { + group.measurement_time(MEASUREMENT_TIME); + group.sample_size(SAMPLE_SIZE); +} + +criterion_group!( + mmr_belt_benches, + bench_mmr_belt_build, + bench_mmr_belt_append, + bench_mmr_belt_commitment, + bench_mmr_belt_open, + bench_mmr_belt_verify, + bench_mmr_belt_recency, +); +criterion_main!(mmr_belt_benches); diff --git a/miden-crypto/src/merkle/mmr/belt.rs b/miden-crypto/src/merkle/mmr/belt.rs new file mode 100644 index 0000000000..208b5fbdcf --- /dev/null +++ b/miden-crypto/src/merkle/mmr/belt.rs @@ -0,0 +1,1369 @@ +use alloc::{collections::BTreeMap, rc::Rc, vec::Vec}; +use core::ops::Range; + +use super::{Forest, MmrError}; +use crate::{EMPTY_WORD, Word, hash::poseidon2::Poseidon2}; + +/// Prototype implementation of the Merkle Mountain Belt construction. +/// +/// This type is intentionally reference-oriented: it keeps live mountains in a linked list and +/// re-derives belt ranges, summaries, and proofs from that list while the paper mechanics settle. +/// Appends use stable mountain slots, local links, and a mergeable-pair stack; the verifier-facing +/// geometry is still reconstructed from the leaf count in `O(log n)`. What is not yet implemented +/// is the paper's indexed hash-array storage and increment proofs. +#[derive(Debug, Clone, Default)] +pub struct MmrBelt { + mountains: Vec>, + free_mountain_slots: Vec, + head: Option, + tail: Option, + mergeable_pairs: Vec, + next_slot_generation: u64, + num_leaves: usize, +} + +impl MmrBelt { + pub fn new() -> Self { + Self::default() + } + + pub fn add(&mut self, leaf: Word) -> Result { + if self.num_leaves >= Forest::MAX_LEAVES { + return Err(MmrError::ForestSizeExceeded { + requested: self.num_leaves.saturating_add(1), + max: Forest::MAX_LEAVES, + }); + } + + let new_idx = self.push_mountain(BeltMountain::new(self.num_leaves, leaf)); + self.num_leaves += 1; + if let Some(prev_idx) = self.mountain_slot(new_idx).prev { + self.track_mergeable_pair(prev_idx, new_idx); + } + + if let Some(right_idx) = self.pop_mergeable_pair() { + self.merge_pair(right_idx); + Ok(1) + } else { + Ok(0) + } + } + + #[cfg(test)] + fn mountain_heights(&self) -> Vec { + self.ordered_mountains().iter().map(|mountain| mountain.height).collect() + } + + #[cfg(test)] + fn storage_slots_for_testing(&self) -> usize { + self.mountains.len() + } + + pub fn num_leaves(&self) -> usize { + self.num_leaves + } + + /// Returns the ordered mountain peaks: the additive mountain-order summary. + /// + /// These are the mountain roots from left (oldest) to right (newest). Unlike the single + /// double-bagged [`BeltSummary::root`], this list is incremental: after a `k`-increment only + /// its `O(log k)` rightmost entries change (see [`MmrBelt::delta`]). + pub fn peaks(&self) -> Vec { + self.ordered_mountains().iter().map(|mountain| mountain.root()).collect() + } + + pub fn summary(&self) -> BeltSummary { + BeltSummary { + num_leaves: self.num_leaves, + root: bag_peaks(self.num_leaves, &self.peaks()), + } + } + + /// Returns the delta needed to update a mountain-order summary from `from_num_leaves` leaves to + /// the current state. + /// + /// By Lemma 9 of the MMB paper, the peak lists of two states differ in only `O(log k)` hashes, + /// all at the rightmost end, so the delta carries just the changed tail of the peak list. It + /// also carries the `O(log² k)` within-mountain authentication nodes needed to extend any + /// tracked leaf whose mountain merged, so a [`PartialMmrBelt`] never has to re-track. + /// + /// # Errors + /// Returns an error if `from_num_leaves` exceeds the current leaf count. + pub fn delta(&self, from_num_leaves: usize) -> Result { + if from_num_leaves > self.num_leaves { + return Err(MmrError::ForestOutOfBounds(from_num_leaves, self.num_leaves)); + } + + let common = common_peak_prefix_len(from_num_leaves, self.num_leaves); + let peaks = self.peaks(); + + let mountains = self.ordered_mountains(); + let from_shape = shape_mountains(from_num_leaves); + let to_shape = shape_mountains(self.num_leaves); + let mut merge_auth = BTreeMap::new(); + + for absorbed in &from_shape[common..] { + let to_idx = shape_mountain_for_position(&to_shape, absorbed.start) + .expect("absorbed leaves still exist in the new state"); + let to_height = to_shape[to_idx].height; + + for (_, sibling_start, height) in + climb_to_peak(absorbed.start, absorbed.height, to_height) + { + merge_auth + .entry((sibling_start, height)) + .or_insert_with(|| self.node_at(&mountains, sibling_start, height)); + } + } + + Ok(MmrBeltDelta { + from_num_leaves, + to_num_leaves: self.num_leaves, + new_tail_peaks: peaks[common..].to_vec(), + merge_auth, + }) + } + + fn node_at(&self, mountains: &[&BeltMountain], start: usize, height: usize) -> Word { + let mountain = mountains + .iter() + .find(|mountain| mountain.start <= start && start < mountain.start + mountain.size()) + .expect("node must lie within a mountain"); + mountain.node_at(start, height) + } + + pub fn open(&self, position: usize) -> Result { + if position >= self.num_leaves { + return Err(MmrError::PositionNotFound(position)); + } + + let mountains = self.ordered_mountains(); + let shape = shape_from_mountains(&mountains); + let mountain_idx = shape_mountain_for_position(&shape, position) + .ok_or(MmrError::PositionNotFound(position))?; + let mountain = mountains[mountain_idx]; + let (leaf, mut nodes) = mountain.open(position - mountain.start); + + let peaks = mountains.iter().map(|mountain| mountain.root()).collect::>(); + nodes.extend(bagging_path_nodes(&peaks, &shape_ranges(&shape), mountain_idx)); + + Ok(BeltProof { position, leaf, nodes }) + } + + #[cfg(test)] + fn range_heights(&self) -> Vec> { + let mountains = self.ordered_mountains(); + let shape = shape_from_mountains(&mountains); + shape_ranges(&shape) + .into_iter() + .map(|range| shape[range].iter().map(|mountain| mountain.height).collect()) + .collect() + } + + fn push_mountain(&mut self, mountain: BeltMountain) -> usize { + let generation = self.next_slot_generation; + self.next_slot_generation += 1; + let slot = BeltMountainSlot { + mountain, + prev: self.tail, + next: None, + generation, + }; + + let idx = if let Some(idx) = self.free_mountain_slots.pop() { + self.mountains[idx] = Some(slot); + idx + } else { + let idx = self.mountains.len(); + self.mountains.push(Some(slot)); + idx + }; + + if let Some(tail_idx) = self.tail { + self.mountain_slot_mut(tail_idx).next = Some(idx); + } else { + self.head = Some(idx); + } + self.tail = Some(idx); + + idx + } + + fn merge_pair(&mut self, right_idx: usize) { + let left_idx = self + .mountain_slot(right_idx) + .prev + .expect("right member of mergeable pair must have left neighbor"); + let right_next = self.mountain_slot(right_idx).next; + let left_prev = self.mountain_slot(left_idx).prev; + let left_generation = self.mountain_slot(left_idx).generation; + + let right = self.mountains[right_idx] + .take() + .expect("right member of mergeable pair must be active"); + let left = self.mountains[left_idx] + .take() + .expect("left member of mergeable pair must be active"); + let merged = left.mountain.merge(right.mountain); + + self.mountains[left_idx] = Some(BeltMountainSlot { + mountain: merged, + prev: left_prev, + next: right_next, + generation: left_generation, + }); + + if let Some(next_idx) = right_next { + self.mountain_slot_mut(next_idx).prev = Some(left_idx); + } else { + self.tail = Some(left_idx); + } + self.free_mountain_slots.push(right_idx); + + if let Some(prev_idx) = left_prev { + self.track_mergeable_pair(prev_idx, left_idx); + } + if let Some(next_idx) = right_next { + self.track_mergeable_pair(left_idx, next_idx); + } + } + + fn track_mergeable_pair(&mut self, left_idx: usize, right_idx: usize) { + if self.is_mergeable_pair(left_idx, right_idx) { + self.mergeable_pairs.push(MergeablePair { + right_idx, + right_generation: self.mountain_slot(right_idx).generation, + }); + } + } + + fn pop_mergeable_pair(&mut self) -> Option { + while let Some(pair) = self.mergeable_pairs.pop() { + let right_idx = pair.right_idx; + let Some(right_slot) = self.mountains.get(right_idx).and_then(Option::as_ref) else { + continue; + }; + if right_slot.generation != pair.right_generation { + continue; + } + let Some(left_idx) = right_slot.prev else { + continue; + }; + + if self.is_mergeable_pair(left_idx, right_idx) { + return Some(right_idx); + } + } + + None + } + + fn is_mergeable_pair(&self, left_idx: usize, right_idx: usize) -> bool { + let Some(left_slot) = self.mountains.get(left_idx).and_then(Option::as_ref) else { + return false; + }; + let Some(right_slot) = self.mountains.get(right_idx).and_then(Option::as_ref) else { + return false; + }; + + left_slot.next == Some(right_idx) + && right_slot.prev == Some(left_idx) + && left_slot.mountain.height == right_slot.mountain.height + } + + fn ordered_mountains(&self) -> Vec<&BeltMountain> { + let mut mountains = Vec::new(); + let mut next = self.head; + while let Some(idx) = next { + let slot = self.mountain_slot(idx); + mountains.push(&slot.mountain); + next = slot.next; + } + mountains + } + + fn mountain_slot(&self, idx: usize) -> &BeltMountainSlot { + self.mountains[idx].as_ref().expect("mountain slot must be active") + } + + fn mountain_slot_mut(&mut self, idx: usize) -> &mut BeltMountainSlot { + self.mountains[idx].as_mut().expect("mountain slot must be active") + } +} + +#[derive(Debug, Clone)] +struct BeltMountainSlot { + mountain: BeltMountain, + prev: Option, + next: Option, + generation: u64, +} + +#[derive(Debug, Clone, Copy)] +struct MergeablePair { + right_idx: usize, + right_generation: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BeltSummary { + num_leaves: usize, + root: Word, +} + +impl BeltSummary { + /// Builds a summary by double-bagging a mountain-order peak list. + /// + /// This lets a client that maintains only the additive peak list (see [`MmrBelt::peaks`]) + /// derive the same `O(1)` commitment a full node publishes, without storing the belt + /// itself. + /// + /// # Errors + /// Returns an error if the number of peaks does not match the shape implied by `num_leaves`. + pub fn from_peaks(num_leaves: usize, peaks: &[Word]) -> Result { + let expected = shape_mountains(num_leaves).len(); + if peaks.len() != expected { + return Err(MmrError::InvalidPeaks(format!( + "expected {expected} peaks for {num_leaves} leaves but got {}", + peaks.len() + ))); + } + + Ok(Self { + num_leaves, + root: bag_peaks(num_leaves, peaks), + }) + } + + /// Returns the number of leaves authenticated by this summary. + /// + /// The authenticated commitment is the pair `(num_leaves, root)`. The root alone is not a + /// length-binding commitment, matching the convention used by the frontier benchmarks. + pub fn num_leaves(&self) -> usize { + self.num_leaves + } + + pub fn root(&self) -> Word { + self.root + } +} + +/// An incremental update to a mountain-order summary, carrying only the peaks that changed during a +/// `k`-increment. +/// +/// The unchanged peaks form a prefix of the peak list (the leftmost mountains never participate in +/// a merge during the increment), so only the rightmost `O(log k)` peaks need to be transmitted. A +/// client applies it to its summary with [`MmrBeltDelta::apply`], and to its tracked leaves with +/// [`PartialMmrBelt::apply`], using the carried `merge_auth` nodes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MmrBeltDelta { + from_num_leaves: usize, + to_num_leaves: usize, + new_tail_peaks: Vec, + /// Within-mountain authentication nodes, keyed by their `(start, height)` coordinate, that let + /// a client extend the path of any tracked leaf whose mountain merged during the + /// increment. + merge_auth: BTreeMap<(usize, usize), Word>, +} + +impl MmrBeltDelta { + pub fn from_num_leaves(&self) -> usize { + self.from_num_leaves + } + + pub fn to_num_leaves(&self) -> usize { + self.to_num_leaves + } + + pub fn new_tail_peaks(&self) -> &[Word] { + &self.new_tail_peaks + } + + pub fn num_merge_auth_nodes(&self) -> usize { + self.merge_auth.len() + } + + /// Applies this delta to a client's `old_peaks` (the mountain-order summary at + /// [`Self::from_num_leaves`]), returning the updated peak list at [`Self::to_num_leaves`]. + /// + /// The unchanged prefix length is recomputed from the leaf counts alone, so a client does not + /// trust the producer's split point. + /// + /// # Errors + /// Returns an error if `old_peaks` does not match the shape implied by + /// [`Self::from_num_leaves`], or if the delta's tail does not complete the target peak + /// list. + pub fn apply(&self, old_peaks: &[Word]) -> Result, MmrError> { + let old_len = shape_mountains(self.from_num_leaves).len(); + if old_peaks.len() != old_len { + return Err(MmrError::InvalidPeaks(format!( + "expected {old_len} peaks for {} leaves but got {}", + self.from_num_leaves, + old_peaks.len() + ))); + } + + let common = common_peak_prefix_len(self.from_num_leaves, self.to_num_leaves); + let new_len = shape_mountains(self.to_num_leaves).len(); + if common + self.new_tail_peaks.len() != new_len { + return Err(MmrError::InvalidUpdate); + } + + let mut peaks = old_peaks[..common].to_vec(); + peaks.extend_from_slice(&self.new_tail_peaks); + + Ok(peaks) + } +} + +// PARTIAL MERKLE MOUNTAIN BELT +// ================================================================================================ + +/// A client-side view of a Merkle Mountain Belt. +/// +/// It stores the mountain-order summary `(num_leaves, peaks)` — enough to derive the `O(1)` +/// commitment locally and to authenticate newly tracked leaves — plus the within-mountain +/// authentication path of a tracked subset of leaves. The bagging layers (range and belt nodes) are +/// not stored: a client holding all mountain peaks rebuilds them locally on demand (Lemma 15 of the +/// MMB paper), which keeps tracked state small and lets every append re-bag for free. +/// +/// A tracked leaf's within-mountain path is invariant as long as its mountain does not merge (the +/// covered leaves are immutable). [`Self::apply`] extends tracked leaves in place when their +/// mountains merge during the increment, using the delta's carried authentication nodes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PartialMmrBelt { + num_leaves: usize, + peaks: Vec, + tracked: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TrackedLeaf { + leaf: Word, + mountain_start: usize, + mountain_height: usize, + within_path: Vec, +} + +impl PartialMmrBelt { + /// Bootstraps a client view from a trusted mountain-order summary. + /// + /// # Errors + /// Returns an error if the number of peaks does not match the shape implied by `num_leaves`. + pub fn from_peaks(num_leaves: usize, peaks: Vec) -> Result { + let expected = shape_mountains(num_leaves).len(); + if peaks.len() != expected { + return Err(MmrError::InvalidPeaks(format!( + "expected {expected} peaks for {num_leaves} leaves but got {}", + peaks.len() + ))); + } + + Ok(Self { + num_leaves, + peaks, + tracked: BTreeMap::new(), + }) + } + + pub fn num_leaves(&self) -> usize { + self.num_leaves + } + + pub fn peaks(&self) -> &[Word] { + &self.peaks + } + + pub fn summary(&self) -> BeltSummary { + BeltSummary { + num_leaves: self.num_leaves, + root: bag_peaks(self.num_leaves, &self.peaks), + } + } + + pub fn is_tracked(&self, pos: usize) -> bool { + self.tracked.contains_key(&pos) + } + + pub fn num_tracked(&self) -> usize { + self.tracked.len() + } + + pub fn get(&self, pos: usize) -> Option { + self.tracked.get(&pos).map(|tracked| tracked.leaf) + } + + /// Starts tracking the leaf authenticated by `proof` against the current summary. + /// + /// # Errors + /// Returns an error if the proof does not authenticate against the current summary, or if its + /// position is out of range. + pub fn track(&mut self, proof: &BeltProof) -> Result<(), MmrError> { + if !proof.verify(&self.summary()) { + return Err(MmrError::PeakPathMismatch); + } + + let shape = shape_mountains(self.num_leaves); + let mountain_idx = shape_mountain_for_position(&shape, proof.position()) + .ok_or(MmrError::PositionNotFound(proof.position()))?; + let mountain = shape[mountain_idx]; + + self.tracked.insert( + proof.position(), + TrackedLeaf { + leaf: proof.leaf(), + mountain_start: mountain.start, + mountain_height: mountain.height, + within_path: proof.nodes[..mountain.height].to_vec(), + }, + ); + + Ok(()) + } + + pub fn untrack(&mut self, pos: usize) -> bool { + self.tracked.remove(&pos).is_some() + } + + /// Rebuilds a membership proof for a tracked leaf, deriving the bagging path from local peaks. + pub fn open(&self, pos: usize) -> Result, MmrError> { + let Some(tracked) = self.tracked.get(&pos) else { + return Ok(None); + }; + + let shape = shape_mountains(self.num_leaves); + let mountain_idx = + shape_mountain_for_position(&shape, pos).ok_or(MmrError::PositionNotFound(pos))?; + + let mut nodes = tracked.within_path.clone(); + nodes.extend(bagging_path_nodes(&self.peaks, &shape_ranges(&shape), mountain_idx)); + + Ok(Some(BeltProof { position: pos, leaf: tracked.leaf, nodes })) + } + + /// Applies an increment delta, advancing the summary and every tracked leaf to the delta's + /// target state. + /// + /// Tracked leaves whose mountain merged during the increment have their within-mountain path + /// extended in place using the delta's authentication nodes, so no re-tracking is ever needed. + /// + /// # Errors + /// Returns an error if the delta does not originate from the current state, if it does not + /// apply cleanly to the current peaks, or if it lacks an authentication node required to + /// extend a tracked leaf. + pub fn apply(&mut self, delta: &MmrBeltDelta) -> Result<(), MmrError> { + if delta.from_num_leaves() != self.num_leaves { + return Err(MmrError::InvalidUpdate); + } + + self.peaks = delta.apply(&self.peaks)?; + self.num_leaves = delta.to_num_leaves(); + + let new_shape = shape_mountains(self.num_leaves); + for (&pos, tracked) in self.tracked.iter_mut() { + let mountain_idx = shape_mountain_for_position(&new_shape, pos) + .ok_or(MmrError::PositionNotFound(pos))?; + let new_mountain = new_shape[mountain_idx]; + + for (node_start, sibling_start, height) in + climb_to_peak(tracked.mountain_start, tracked.mountain_height, new_mountain.height) + { + let &value = delta + .merge_auth + .get(&(sibling_start, height)) + .ok_or(MmrError::InvalidUpdate)?; + tracked.within_path.push(BeltProofNode { + value, + side: sibling_side((node_start >> height) & 1 == 0), + }); + } + + tracked.mountain_start = new_mountain.start; + tracked.mountain_height = new_mountain.height; + } + + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BeltProof { + position: usize, + leaf: Word, + nodes: Vec, +} + +impl BeltProof { + pub fn position(&self) -> usize { + self.position + } + + pub fn leaf(&self) -> Word { + self.leaf + } + + pub fn verify(&self, summary: &BeltSummary) -> bool { + if self.position >= summary.num_leaves { + return false; + } + + let Some(expected_sides) = proof_sides_for_position(summary.num_leaves, self.position) + else { + return false; + }; + + if self.nodes.len() != expected_sides.len() + || self + .nodes + .iter() + .zip(expected_sides) + .any(|(node, expected_side)| node.side != expected_side) + { + return false; + } + + let root = self.nodes.iter().fold(self.leaf, |current, node| match node.side { + SiblingSide::Left => Poseidon2::merge(&[node.value, current]), + SiblingSide::Right => Poseidon2::merge(&[current, node.value]), + }); + + root == summary.root + } + + #[cfg(test)] + fn set_leaf_for_testing(&mut self, leaf: Word) { + self.leaf = leaf; + } + + #[cfg(test)] + fn set_position_for_testing(&mut self, position: usize) { + self.position = position; + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BeltProofNode { + value: Word, + side: SiblingSide, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SiblingSide { + Left, + Right, +} + +#[derive(Debug, Clone)] +struct BeltMountain { + start: usize, + height: usize, + node: Rc, +} + +impl BeltMountain { + fn new(start: usize, leaf: Word) -> Self { + Self { + start, + height: 0, + node: Rc::new(MountainNode::Leaf(leaf)), + } + } + + fn merge(self, other: Self) -> Self { + debug_assert_eq!(self.height, other.height); + debug_assert_eq!(self.start + self.size(), other.start); + + let root = Poseidon2::merge(&[self.root(), other.root()]); + Self { + start: self.start, + height: self.height + 1, + node: Rc::new(MountainNode::Inner { root, left: self.node, right: other.node }), + } + } + + fn size(&self) -> usize { + 1usize << self.height + } + + fn root(&self) -> Word { + self.node.root() + } + + fn open(&self, local_position: usize) -> (Word, Vec) { + let mut path = Vec::with_capacity(self.height); + let leaf = self.node.open(self.height, local_position, &mut path); + (leaf, path) + } + + fn node_at(&self, target_start: usize, target_height: usize) -> Word { + self.node.node_at(self.start, self.height, target_start, target_height) + } +} + +#[derive(Debug, Clone)] +enum MountainNode { + Leaf(Word), + Inner { + root: Word, + left: Rc, + right: Rc, + }, +} + +impl MountainNode { + fn root(&self) -> Word { + match self { + Self::Leaf(root) | Self::Inner { root, .. } => *root, + } + } + + fn open(&self, height: usize, local_position: usize, path: &mut Vec) -> Word { + match self { + Self::Leaf(leaf) => *leaf, + Self::Inner { left, right, .. } => { + let half = 1usize << (height - 1); + if local_position < half { + let leaf = left.open(height - 1, local_position, path); + path.push(BeltProofNode { + value: right.root(), + side: SiblingSide::Right, + }); + leaf + } else { + let leaf = right.open(height - 1, local_position - half, path); + path.push(BeltProofNode { + value: left.root(), + side: SiblingSide::Left, + }); + leaf + } + }, + } + } + + fn node_at( + &self, + cur_start: usize, + cur_height: usize, + target_start: usize, + target_height: usize, + ) -> Word { + if cur_height == target_height { + debug_assert_eq!(cur_start, target_start); + return self.root(); + } + + match self { + Self::Leaf(_) => unreachable!("target height is below a leaf"), + Self::Inner { left, right, .. } => { + let mid = cur_start + (1usize << (cur_height - 1)); + if target_start < mid { + left.node_at(cur_start, cur_height - 1, target_start, target_height) + } else { + right.node_at(mid, cur_height - 1, target_start, target_height) + } + }, + } + } +} + +fn forward_bag(nodes: T) -> Word +where + T: IntoIterator, +{ + let mut iter = nodes.into_iter(); + let Some(first) = iter.next() else { + return EMPTY_WORD; + }; + + iter.fold(first, |left, right| Poseidon2::merge(&[left, right])) +} + +// The target's subtree is the left child at every level it climbs, so its path is the prefix bag +// to its left (one node, if any) followed by each peak to its right. +fn forward_tree_path(nodes: &[Word], target_idx: usize) -> Vec { + debug_assert!(!nodes.is_empty()); + debug_assert!(target_idx < nodes.len()); + + let mut path = Vec::with_capacity(nodes.len() - target_idx); + if target_idx > 0 { + let left = forward_bag(nodes[..target_idx].iter().copied()); + path.push(BeltProofNode { value: left, side: SiblingSide::Left }); + } + for &node in &nodes[target_idx + 1..] { + path.push(BeltProofNode { value: node, side: SiblingSide::Right }); + } + path +} + +fn bagging_path_nodes( + peaks: &[Word], + ranges: &[Range], + mountain_idx: usize, +) -> Vec { + let range_idx = ranges + .iter() + .position(|range| range.contains(&mountain_idx)) + .expect("mountain must be part of a range"); + let range = ranges[range_idx].clone(); + + let mut nodes = forward_tree_path(&peaks[range.clone()], mountain_idx - range.start); + let range_roots = ranges + .iter() + .map(|range| forward_bag(peaks[range.clone()].iter().copied())) + .collect::>(); + nodes.extend(forward_tree_path(&range_roots, range_idx)); + nodes +} + +fn proof_sides_for_position(num_leaves: usize, position: usize) -> Option> { + if position >= num_leaves { + return None; + } + + let mountains = shape_mountains(num_leaves); + let mountain_idx = shape_mountain_for_position(&mountains, position)?; + let mountain = mountains[mountain_idx]; + let local_position = position - mountain.start; + + let mut sides = balanced_tree_sides(mountain.height, local_position); + + let ranges = shape_ranges(&mountains); + let range_idx = ranges.iter().position(|range| range.contains(&mountain_idx))?; + let range = ranges[range_idx].clone(); + sides.extend(forward_tree_sides(range.len(), mountain_idx - range.start)); + sides.extend(forward_tree_sides(ranges.len(), range_idx)); + + Some(sides) +} + +/// This is the common currency for all belt geometry: both the prover (over its live mountain +/// list) and the verifier (over a shape derived solely from the leaf count) reduce to a slice of +/// `ShapeMountain` before computing ranges, positions, and proof handedness. +#[derive(Debug, Clone, Copy)] +struct ShapeMountain { + start: usize, + height: usize, +} + +impl ShapeMountain { + fn size(&self) -> usize { + 1usize << self.height + } +} + +fn shape_from_mountains(mountains: &[&BeltMountain]) -> Vec { + mountains + .iter() + .map(|mountain| ShapeMountain { + start: mountain.start, + height: mountain.height, + }) + .collect() +} + +/// Derives the left-to-right mountain shape directly from the leaf count, in O(log n) time. +/// +/// By Lemma 6 of the MMB paper, if `num_leaves + 1 = (b_t .. b_1 b_0)` in binary then there are +/// `t = floor(log2(num_leaves + 1))` mountains, and the mountain at position `i` (counted from the +/// right, starting at zero) has height `s_i = i + b_i`; the leading bit `b_t` is ignored. This lets +/// a verifier reconstruct the geometry without replaying every append. +fn shape_mountains(num_leaves: usize) -> Vec { + if num_leaves == 0 { + return Vec::new(); + } + + let bits = num_leaves + 1; + let num_mountains = bits.ilog2() as usize; + + let mut mountains = Vec::with_capacity(num_mountains); + let mut start = 0; + for position in (0..num_mountains).rev() { + let bit = (bits >> position) & 1; + let height = position + bit; + mountains.push(ShapeMountain { start, height }); + start += 1usize << height; + } + debug_assert_eq!(start, num_leaves); + + mountains +} + +fn shape_mountain_for_position(mountains: &[ShapeMountain], position: usize) -> Option { + mountains + .iter() + .position(|mountain| position < mountain.start + mountain.size()) +} + +fn shape_ranges(mountains: &[ShapeMountain]) -> Vec> { + if mountains.is_empty() { + return Vec::new(); + } + + let mut ranges = Vec::new(); + let mut start = 0; + for idx in 0..mountains.len() - 1 { + if shape_range_split_after(mountains, idx) { + ranges.push(start..idx + 1); + start = idx + 1; + } + } + ranges.push(start..mountains.len()); + ranges +} + +fn shape_range_split_after(mountains: &[ShapeMountain], idx: usize) -> bool { + let left = mountains[idx].height; + let right = mountains[idx + 1].height; + let drops_by_two = left == right + 2; + let left_is_right_member_of_mergeable_pair = idx > 0 && mountains[idx - 1].height == left; + + drops_by_two || left_is_right_member_of_mergeable_pair +} + +// `start` is a multiple of `2^height`, so the parity of `start >> height` decides handedness: even +// is a left child (sibling to the right), odd is a right child (sibling to the left). +fn sibling_and_parent_start(start: usize, height: usize) -> (usize, usize) { + let span = 1usize << height; + if (start >> height) & 1 == 0 { + (start + span, start) + } else { + (start - span, start - span) + } +} + +// The delta producer and client walk this same sequence to avoid path-extension drift. +fn climb_to_peak( + start: usize, + from_height: usize, + to_height: usize, +) -> impl Iterator { + let mut start = start; + (from_height..to_height).map(move |height| { + let node_start = start; + let (sibling_start, parent_start) = sibling_and_parent_start(start, height); + start = parent_start; + (node_start, sibling_start, height) + }) +} + +fn bag_peaks(num_leaves: usize, peaks: &[Word]) -> Word { + let shape = shape_mountains(num_leaves); + debug_assert_eq!(shape.len(), peaks.len()); + + let range_roots = shape_ranges(&shape) + .into_iter() + .map(|range| forward_bag(peaks[range].iter().copied())) + .collect::>(); + + forward_bag(range_roots) +} + +/// Returns the number of leftmost peaks shared between the `from` and `to` states. +/// +/// Two mountains with the same start offset and height cover the same immutable leaf range, so they +/// have identical peak hashes. The shared prefix is therefore the longest run of position-identical +/// mountains, and everything past it is what a [`MmrBeltDelta`] must carry. +fn common_peak_prefix_len(from_num_leaves: usize, to_num_leaves: usize) -> usize { + let from_shape = shape_mountains(from_num_leaves); + let to_shape = shape_mountains(to_num_leaves); + + from_shape + .iter() + .zip(to_shape.iter()) + .take_while(|(from, to)| from.start == to.start && from.height == to.height) + .count() +} + +fn sibling_side(is_left_child: bool) -> SiblingSide { + if is_left_child { + SiblingSide::Right + } else { + SiblingSide::Left + } +} + +fn balanced_tree_sides(height: usize, mut local_position: usize) -> Vec { + let mut sides = Vec::with_capacity(height); + for _ in 0..height { + sides.push(sibling_side(local_position & 1 == 0)); + local_position >>= 1; + } + sides +} + +fn forward_tree_sides(len: usize, target_idx: usize) -> Vec { + debug_assert!(len > 0); + debug_assert!(target_idx < len); + + let mut sides = Vec::with_capacity(len - target_idx); + if target_idx > 0 { + sides.push(SiblingSide::Left); + } + sides.resize(sides.len() + (len - 1 - target_idx), SiblingSide::Right); + sides +} + +#[cfg(test)] +mod tests { + use alloc::{vec, vec::Vec}; + + use super::{BeltSummary, MmrBelt, PartialMmrBelt}; + use crate::merkle::int_to_node; + + #[test] + fn belt_lazy_append_height_sequence() { + let mut belt = MmrBelt::new(); + let expected = [ + vec![0], + vec![1], + vec![1, 0], + vec![1, 1], + vec![2, 0], + vec![2, 1], + vec![2, 1, 0], + vec![2, 1, 1], + vec![2, 2, 0], + vec![2, 2, 1], + vec![3, 1, 0], + vec![3, 1, 1], + vec![3, 2, 0], + ]; + + for (idx, expected_heights) in expected.into_iter().enumerate() { + belt.add(int_to_node(idx as u64)).unwrap(); + assert_eq!(belt.mountain_heights(), expected_heights); + } + } + + #[test] + fn belt_append_performs_at_most_one_mountain_merge() { + let mut belt = MmrBelt::new(); + + for idx in 0..128 { + let merge_count = belt.add(int_to_node(idx)).unwrap(); + assert!(merge_count <= 1); + } + } + + #[test] + fn belt_append_touches_constant_local_storage() { + let mut belt = MmrBelt::new(); + + for idx in 0..128 { + let before = belt.storage_slots_for_testing(); + belt.add(int_to_node(idx)).unwrap(); + let after = belt.storage_slots_for_testing(); + + assert!(after - before <= 2); + } + } + + #[test] + fn belt_range_splits_follow_mmb_rules() { + let mut belt = MmrBelt::new(); + let expected = [ + vec![vec![0]], + vec![vec![1]], + vec![vec![1, 0]], + vec![vec![1, 1]], + vec![vec![2], vec![0]], + vec![vec![2, 1]], + vec![vec![2, 1, 0]], + vec![vec![2, 1, 1]], + vec![vec![2, 2], vec![0]], + vec![vec![2, 2], vec![1]], + vec![vec![3], vec![1, 0]], + vec![vec![3], vec![1, 1]], + vec![vec![3, 2], vec![0]], + vec![vec![3, 2, 1]], + vec![vec![3, 2, 1, 0]], + vec![vec![3, 2, 1, 1]], + ]; + + for (idx, expected_ranges) in expected.into_iter().enumerate() { + belt.add(int_to_node(idx as u64)).unwrap(); + assert_eq!(belt.range_heights(), expected_ranges); + } + } + + #[test] + fn belt_summary_root_is_stable_for_same_leaves() { + let mut first = MmrBelt::new(); + let mut second = MmrBelt::new(); + + for idx in 0..32 { + let leaf = int_to_node(idx); + first.add(leaf).unwrap(); + second.add(leaf).unwrap(); + } + + assert_eq!(first.summary().root(), second.summary().root()); + assert_eq!(first.summary().num_leaves(), 32); + } + + #[test] + fn belt_openings_verify_for_all_leaves() { + let mut belt = MmrBelt::new(); + let leaves = (0..37).map(int_to_node).collect::>(); + for leaf in leaves.iter().copied() { + belt.add(leaf).unwrap(); + } + let summary = belt.summary(); + + for (position, leaf) in leaves.into_iter().enumerate() { + let proof = belt.open(position).unwrap(); + assert_eq!(proof.position(), position); + assert_eq!(proof.leaf(), leaf); + assert!(proof.verify(&summary)); + } + } + + #[test] + fn belt_opening_rejects_wrong_leaf() { + let mut belt = MmrBelt::new(); + for idx in 0..16 { + belt.add(int_to_node(idx)).unwrap(); + } + let summary = belt.summary(); + let mut proof = belt.open(7).unwrap(); + + proof.set_leaf_for_testing(int_to_node(999)); + + assert!(!proof.verify(&summary)); + } + + #[test] + fn belt_opening_rejects_wrong_position() { + let mut belt = MmrBelt::new(); + for idx in 0..37 { + belt.add(int_to_node(idx)).unwrap(); + } + let summary = belt.summary(); + let mut proof = belt.open(7).unwrap(); + + proof.set_position_for_testing(5); + + assert!(!proof.verify(&summary)); + } + + #[test] + fn belt_shape_derivation_matches_live_structure_across_pow2() { + let mut belt = MmrBelt::new(); + let leaves = (0..4100u64).map(int_to_node).collect::>(); + for leaf in leaves.iter().copied() { + belt.add(leaf).unwrap(); + } + let summary = belt.summary(); + + for (position, leaf) in leaves.into_iter().enumerate() { + let proof = belt.open(position).unwrap(); + assert_eq!(proof.leaf(), leaf, "leaf mismatch at {position}"); + assert!(proof.verify(&summary), "verify failed at position {position}"); + } + } + + #[test] + fn belt_summary_from_peaks_matches_full_summary() { + let mut belt = MmrBelt::new(); + for idx in 0..100 { + belt.add(int_to_node(idx)).unwrap(); + let summary = belt.summary(); + let from_peaks = BeltSummary::from_peaks(summary.num_leaves(), &belt.peaks()).unwrap(); + assert_eq!(from_peaks, summary); + } + } + + #[test] + fn belt_delta_resyncs_client_summary() { + let total = 600usize; + for from in [0usize, 1, 7, 64, 255, 256, 511] { + let mut belt = MmrBelt::new(); + for idx in 0..from { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let client_peaks = belt.peaks(); + + for idx in from..total { + belt.add(int_to_node(idx as u64)).unwrap(); + } + + let delta = belt.delta(from).unwrap(); + assert_eq!(delta.from_num_leaves(), from); + assert_eq!(delta.to_num_leaves(), total); + + let updated = delta.apply(&client_peaks).unwrap(); + assert_eq!(updated, belt.peaks(), "resynced peaks must match (from {from})"); + assert_eq!( + BeltSummary::from_peaks(total, &updated).unwrap(), + belt.summary(), + "resynced commitment must match (from {from})" + ); + } + } + + #[test] + fn belt_delta_is_logarithmic_in_increment() { + let mut belt = MmrBelt::new(); + for idx in 0..100_000u64 { + belt.add(int_to_node(idx)).unwrap(); + } + + for k in [1usize, 2, 10, 100, 1000] { + let delta = belt.delta(100_000 - k).unwrap(); + let bound = 2 * (usize::BITS - k.leading_zeros()) as usize + 4; + assert!( + delta.new_tail_peaks().len() <= bound, + "k={k}: tail {} exceeded bound {bound}", + delta.new_tail_peaks().len() + ); + } + } + + #[test] + fn belt_delta_rejects_future_origin() { + let mut belt = MmrBelt::new(); + for idx in 0..10 { + belt.add(int_to_node(idx)).unwrap(); + } + assert!(belt.delta(11).is_err()); + } + + #[test] + fn partial_belt_tracks_and_opens_like_full_belt() { + let mut belt = MmrBelt::new(); + let leaves = (0..50u64).map(int_to_node).collect::>(); + for leaf in leaves.iter().copied() { + belt.add(leaf).unwrap(); + } + + let mut partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); + assert_eq!(partial.summary(), belt.summary()); + + for position in 0..leaves.len() { + partial.track(&belt.open(position).unwrap()).unwrap(); + } + assert_eq!(partial.num_tracked(), leaves.len()); + + for (position, leaf) in leaves.iter().copied().enumerate() { + let proof = partial.open(position).unwrap().unwrap(); + assert_eq!(proof, belt.open(position).unwrap()); + assert_eq!(partial.get(position), Some(leaf)); + assert!(proof.verify(&partial.summary())); + } + } + + #[test] + fn partial_belt_from_peaks_rejects_wrong_count() { + let mut belt = MmrBelt::new(); + for idx in 0..7 { + belt.add(int_to_node(idx)).unwrap(); + } + let mut peaks = belt.peaks(); + peaks.pop(); + assert!(PartialMmrBelt::from_peaks(belt.num_leaves(), peaks).is_err()); + } + + #[test] + fn partial_belt_track_rejects_unauthenticated_proof() { + let mut belt = MmrBelt::new(); + for idx in 0..16 { + belt.add(int_to_node(idx)).unwrap(); + } + let mut partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); + + let mut proof = belt.open(5).unwrap(); + proof.set_leaf_for_testing(int_to_node(999)); + assert!(partial.track(&proof).is_err()); + assert!(!partial.is_tracked(5)); + } + + #[test] + fn partial_belt_apply_extends_all_tracks_in_place() { + let from = 200usize; + let to = 260usize; + + let mut belt = MmrBelt::new(); + for idx in 0..from { + belt.add(int_to_node(idx as u64)).unwrap(); + } + + let mut partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); + for position in 0..from { + partial.track(&belt.open(position).unwrap()).unwrap(); + } + + for idx in from..to { + belt.add(int_to_node(idx as u64)).unwrap(); + } + + partial.apply(&belt.delta(from).unwrap()).unwrap(); + + assert_eq!(partial.num_leaves(), to); + assert_eq!(partial.summary(), belt.summary()); + assert_eq!(partial.num_tracked(), from); + + for position in 0..from { + assert!(partial.is_tracked(position), "leaf {position} must still be tracked"); + assert_eq!(partial.open(position).unwrap().unwrap(), belt.open(position).unwrap()); + } + } + + #[test] + fn partial_belt_apply_extends_across_many_increments() { + let mut belt = MmrBelt::new(); + for idx in 0..40u64 { + belt.add(int_to_node(idx)).unwrap(); + } + + let mut partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); + let tracked = [0usize, 1, 17, 38, 39]; + for &position in &tracked { + partial.track(&belt.open(position).unwrap()).unwrap(); + } + + let mut next = 40u64; + for _ in 0..30 { + let from = belt.num_leaves(); + for _ in 0..7 { + belt.add(int_to_node(next)).unwrap(); + next += 1; + } + partial.apply(&belt.delta(from).unwrap()).unwrap(); + + assert_eq!(partial.summary(), belt.summary()); + for &position in &tracked { + assert_eq!(partial.open(position).unwrap().unwrap(), belt.open(position).unwrap()); + } + } + } + + #[test] + fn partial_belt_delta_merge_auth_is_polylogarithmic() { + let mut belt = MmrBelt::new(); + for idx in 0..100_000u64 { + belt.add(int_to_node(idx)).unwrap(); + } + + for k in [1usize, 2, 16, 256, 4096] { + let delta = belt.delta(100_000 - k).unwrap(); + let log_k = (usize::BITS - k.leading_zeros()) as usize; + let bound = 4 * log_k * log_k + 8; + assert!( + delta.num_merge_auth_nodes() <= bound, + "k={k}: {} auth nodes exceeded bound {bound}", + delta.num_merge_auth_nodes() + ); + } + } + + #[test] + fn partial_belt_open_untracked_returns_none() { + let mut belt = MmrBelt::new(); + for idx in 0..16 { + belt.add(int_to_node(idx)).unwrap(); + } + let partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); + assert!(partial.open(3).unwrap().is_none()); + } +} diff --git a/miden-crypto/src/merkle/mmr/mod.rs b/miden-crypto/src/merkle/mmr/mod.rs index 1bdf44b532..6d153a9993 100644 --- a/miden-crypto/src/merkle/mmr/mod.rs +++ b/miden-crypto/src/merkle/mmr/mod.rs @@ -1,5 +1,9 @@ //! Merkle Mountain Range (MMR) data structures. +#[cfg(feature = "internal")] +pub mod belt; +#[cfg(not(feature = "internal"))] +pub(crate) mod belt; mod delta; mod error; mod forest; From eb2c9a009d8d9d67dd9c64b01ea21692005bb3ec Mon Sep 17 00:00:00 2001 From: krushimir <189111540+krushimir@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:00:06 +0200 Subject: [PATCH 02/10] feat(mmr): add indexed MMB storage and partial resync --- miden-crypto/benches/mmr_belt.rs | 49 +++- miden-crypto/src/merkle/mmr/belt.rs | 394 ++++++++++++++++++++-------- 2 files changed, 329 insertions(+), 114 deletions(-) diff --git a/miden-crypto/benches/mmr_belt.rs b/miden-crypto/benches/mmr_belt.rs index c7b9ad044c..99ea7e98bf 100644 --- a/miden-crypto/benches/mmr_belt.rs +++ b/miden-crypto/benches/mmr_belt.rs @@ -1,7 +1,7 @@ //! MMR vs experimental Merkle Mountain Belt benchmarks. //! //! The belt implementation benchmarked here is a reference prototype behind the `internal` -//! feature. It keeps enough in-memory tree state to derive summaries/proofs, so these numbers +//! feature. It keeps in-memory hash-array state to derive summaries/proofs, so these numbers //! should be read as construction-comparison data rather than production storage performance. use std::{hint, time::Duration}; @@ -20,6 +20,7 @@ use common::data::{WordPattern, generate_word_pattern, generate_words_pattern}; const MMR_BELT_SIZES: &[usize] = &[1_000, 1_023, 1_024, 50_000, 65_535, 65_536]; const RECENCIES: &[usize] = &[1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1_024, 4_096, 16_384]; +const APPEND_SEQUENCE_LEN: usize = 1_024; const MEASUREMENT_TIME: Duration = Duration::from_secs(2); const SAMPLE_SIZE: usize = 20; @@ -48,6 +49,12 @@ impl MmrBeltBenchData { } } +fn append_sequence(start: usize) -> Vec { + (start..start + APPEND_SEQUENCE_LEN) + .map(|idx| generate_word_pattern(idx as u64, WordPattern::Sequential)) + .collect() +} + fn bench_mmr_belt_build(c: &mut Criterion) { let mut group = c.benchmark_group("mmr-belt-build"); configure_group(&mut group); @@ -145,6 +152,45 @@ fn bench_mmr_belt_append(c: &mut Criterion) { group.finish(); } +fn bench_mmr_belt_append_sequence(c: &mut Criterion) { + let mut group = c.benchmark_group("mmr-belt-append-sequence"); + configure_group(&mut group); + group.throughput(criterion::Throughput::Elements(APPEND_SEQUENCE_LEN as u64)); + + for &size in MMR_BELT_SIZES { + let data = MmrBeltBenchData::build(size); + let leaves = append_sequence(size); + + group.bench_with_input(BenchmarkId::new("current-mmr", size), &size, |b, _| { + b.iter_batched_ref( + || (data.mmr.clone(), leaves.clone()), + |(mmr, leaves)| { + for leaf in leaves.iter().copied() { + mmr.add(hint::black_box(leaf)).unwrap(); + } + hint::black_box(mmr); + }, + BatchSize::LargeInput, + ); + }); + + group.bench_with_input(BenchmarkId::new("belt-prototype", size), &size, |b, _| { + b.iter_batched_ref( + || (data.belt.clone(), leaves.clone()), + |(belt, leaves)| { + for leaf in leaves.iter().copied() { + belt.add(hint::black_box(leaf)).unwrap(); + } + hint::black_box(belt); + }, + BatchSize::LargeInput, + ); + }); + } + + group.finish(); +} + fn bench_mmr_belt_commitment(c: &mut Criterion) { let mut group = c.benchmark_group("mmr-belt-commitment"); configure_group(&mut group); @@ -366,6 +412,7 @@ criterion_group!( mmr_belt_benches, bench_mmr_belt_build, bench_mmr_belt_append, + bench_mmr_belt_append_sequence, bench_mmr_belt_commitment, bench_mmr_belt_open, bench_mmr_belt_verify, diff --git a/miden-crypto/src/merkle/mmr/belt.rs b/miden-crypto/src/merkle/mmr/belt.rs index 208b5fbdcf..90e4a8c81d 100644 --- a/miden-crypto/src/merkle/mmr/belt.rs +++ b/miden-crypto/src/merkle/mmr/belt.rs @@ -1,19 +1,25 @@ -use alloc::{collections::BTreeMap, rc::Rc, vec::Vec}; +use alloc::{collections::BTreeMap, vec::Vec}; use core::ops::Range; use super::{Forest, MmrError}; -use crate::{EMPTY_WORD, Word, hash::poseidon2::Poseidon2}; +use crate::{EMPTY_WORD, Felt, Word, ZERO, hash::poseidon2::Poseidon2}; + +/// Domain separator for the final root, which binds the leaf count into the commitment. +/// +/// Because the belt shape is a pure function of the leaf count (Lemma 6), binding the count makes +/// the root commit to the entire structure: it cannot be reinterpreted under a different length. +const BELT_ROOT_DOMAIN: Felt = Felt::new_unchecked(0x4d4d_425f_524f_4f54); // "MMB_ROOT" /// Prototype implementation of the Merkle Mountain Belt construction. /// /// This type is intentionally reference-oriented: it keeps live mountains in a linked list and /// re-derives belt ranges, summaries, and proofs from that list while the paper mechanics settle. -/// Appends use stable mountain slots, local links, and a mergeable-pair stack; the verifier-facing -/// geometry is still reconstructed from the leaf count in `O(log n)`. What is not yet implemented -/// is the paper's indexed hash-array storage and increment proofs. +/// Appends use stable mountain slots, local links, a mergeable-pair stack, and indexed hash-array +/// storage for committed nodes. Increment proofs are not yet implemented. #[derive(Debug, Clone, Default)] pub struct MmrBelt { mountains: Vec>, + hashes: BeltHashArray, free_mountain_slots: Vec, head: Option, tail: Option, @@ -35,7 +41,10 @@ impl MmrBelt { }); } - let new_idx = self.push_mountain(BeltMountain::new(self.num_leaves, leaf)); + let leaf_index = leaf_hash_index(self.num_leaves); + self.hashes.set(leaf_index, leaf); + + let new_idx = self.push_mountain(BeltMountain::new(self.num_leaves, leaf_index)); self.num_leaves += 1; if let Some(prev_idx) = self.mountain_slot(new_idx).prev { self.track_mergeable_pair(prev_idx, new_idx); @@ -69,7 +78,10 @@ impl MmrBelt { /// double-bagged [`BeltSummary::root`], this list is incremental: after a `k`-increment only /// its `O(log k)` rightmost entries change (see [`MmrBelt::delta`]). pub fn peaks(&self) -> Vec { - self.ordered_mountains().iter().map(|mountain| mountain.root()).collect() + self.ordered_mountains() + .iter() + .map(|mountain| self.hash_at(mountain.index)) + .collect() } pub fn summary(&self) -> BeltSummary { @@ -97,7 +109,6 @@ impl MmrBelt { let common = common_peak_prefix_len(from_num_leaves, self.num_leaves); let peaks = self.peaks(); - let mountains = self.ordered_mountains(); let from_shape = shape_mountains(from_num_leaves); let to_shape = shape_mountains(self.num_leaves); let mut merge_auth = BTreeMap::new(); @@ -112,7 +123,7 @@ impl MmrBelt { { merge_auth .entry((sibling_start, height)) - .or_insert_with(|| self.node_at(&mountains, sibling_start, height)); + .or_insert_with(|| self.node_at(sibling_start, height)); } } @@ -124,12 +135,12 @@ impl MmrBelt { }) } - fn node_at(&self, mountains: &[&BeltMountain], start: usize, height: usize) -> Word { - let mountain = mountains - .iter() - .find(|mountain| mountain.start <= start && start < mountain.start + mountain.size()) - .expect("node must lie within a mountain"); - mountain.node_at(start, height) + fn node_at(&self, start: usize, height: usize) -> Word { + self.hash_at(node_hash_index(start, height)) + } + + fn hash_at(&self, index: HashIndex) -> Word { + self.hashes.get(index).expect("hash must be present in storage") } pub fn open(&self, position: usize) -> Result { @@ -141,15 +152,39 @@ impl MmrBelt { let shape = shape_from_mountains(&mountains); let mountain_idx = shape_mountain_for_position(&shape, position) .ok_or(MmrError::PositionNotFound(position))?; - let mountain = mountains[mountain_idx]; - let (leaf, mut nodes) = mountain.open(position - mountain.start); + let mountain = shape[mountain_idx]; + let (leaf, mut nodes) = self.open_within_mountain(position, mountain); - let peaks = mountains.iter().map(|mountain| mountain.root()).collect::>(); + let peaks = mountains + .iter() + .map(|mountain| self.hash_at(mountain.index)) + .collect::>(); nodes.extend(bagging_path_nodes(&peaks, &shape_ranges(&shape), mountain_idx)); Ok(BeltProof { position, leaf, nodes }) } + fn open_within_mountain( + &self, + position: usize, + mountain: ShapeMountain, + ) -> (Word, Vec) { + let leaf = self.node_at(position, 0); + let mut nodes = Vec::with_capacity(mountain.height); + let mut node_start = position; + + for height in 0..mountain.height { + let (sibling_start, parent_start) = sibling_and_parent_start(node_start, height); + nodes.push(BeltProofNode { + value: self.node_at(sibling_start, height), + side: sibling_side((node_start >> height) & 1 == 0), + }); + node_start = parent_start; + } + + (leaf, nodes) + } + #[cfg(test)] fn range_heights(&self) -> Vec> { let mountains = self.ordered_mountains(); @@ -204,7 +239,12 @@ impl MmrBelt { let left = self.mountains[left_idx] .take() .expect("left member of mergeable pair must be active"); + let root = Poseidon2::merge(&[ + self.hash_at(left.mountain.index), + self.hash_at(right.mountain.index), + ]); let merged = left.mountain.merge(right.mountain); + self.hashes.set(merged.index, root); self.mountains[left_idx] = Some(BeltMountainSlot { mountain: merged, @@ -305,6 +345,24 @@ struct MergeablePair { right_generation: u64, } +#[derive(Debug, Clone, Default)] +struct BeltHashArray { + nodes: Vec>, +} + +impl BeltHashArray { + fn get(&self, index: HashIndex) -> Option { + self.nodes.get(index.0).copied().flatten() + } + + fn set(&mut self, index: HashIndex, value: Word) { + if self.nodes.len() <= index.0 { + self.nodes.resize(index.0 + 1, None); + } + self.nodes[index.0] = Some(value); + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct BeltSummary { num_leaves: usize, @@ -622,12 +680,12 @@ impl BeltProof { return false; } - let root = self.nodes.iter().fold(self.leaf, |current, node| match node.side { + let bagged = self.nodes.iter().fold(self.leaf, |current, node| match node.side { SiblingSide::Left => Poseidon2::merge(&[node.value, current]), SiblingSide::Right => Poseidon2::merge(&[current, node.value]), }); - root == summary.root + bind_length(bagged, summary.num_leaves) == summary.root } #[cfg(test)] @@ -657,114 +715,30 @@ enum SiblingSide { struct BeltMountain { start: usize, height: usize, - node: Rc, + index: HashIndex, } impl BeltMountain { - fn new(start: usize, leaf: Word) -> Self { - Self { - start, - height: 0, - node: Rc::new(MountainNode::Leaf(leaf)), - } + fn new(start: usize, index: HashIndex) -> Self { + Self { start, height: 0, index } } fn merge(self, other: Self) -> Self { debug_assert_eq!(self.height, other.height); debug_assert_eq!(self.start + self.size(), other.start); - let root = Poseidon2::merge(&[self.root(), other.root()]); + let index = parent_hash_index(self.index); + debug_assert_eq!(index, parent_hash_index(other.index)); Self { start: self.start, height: self.height + 1, - node: Rc::new(MountainNode::Inner { root, left: self.node, right: other.node }), + index, } } fn size(&self) -> usize { 1usize << self.height } - - fn root(&self) -> Word { - self.node.root() - } - - fn open(&self, local_position: usize) -> (Word, Vec) { - let mut path = Vec::with_capacity(self.height); - let leaf = self.node.open(self.height, local_position, &mut path); - (leaf, path) - } - - fn node_at(&self, target_start: usize, target_height: usize) -> Word { - self.node.node_at(self.start, self.height, target_start, target_height) - } -} - -#[derive(Debug, Clone)] -enum MountainNode { - Leaf(Word), - Inner { - root: Word, - left: Rc, - right: Rc, - }, -} - -impl MountainNode { - fn root(&self) -> Word { - match self { - Self::Leaf(root) | Self::Inner { root, .. } => *root, - } - } - - fn open(&self, height: usize, local_position: usize, path: &mut Vec) -> Word { - match self { - Self::Leaf(leaf) => *leaf, - Self::Inner { left, right, .. } => { - let half = 1usize << (height - 1); - if local_position < half { - let leaf = left.open(height - 1, local_position, path); - path.push(BeltProofNode { - value: right.root(), - side: SiblingSide::Right, - }); - leaf - } else { - let leaf = right.open(height - 1, local_position - half, path); - path.push(BeltProofNode { - value: left.root(), - side: SiblingSide::Left, - }); - leaf - } - }, - } - } - - fn node_at( - &self, - cur_start: usize, - cur_height: usize, - target_start: usize, - target_height: usize, - ) -> Word { - if cur_height == target_height { - debug_assert_eq!(cur_start, target_start); - return self.root(); - } - - match self { - Self::Leaf(_) => unreachable!("target height is below a leaf"), - Self::Inner { left, right, .. } => { - let mid = cur_start + (1usize << (cur_height - 1)); - if target_start < mid { - left.node_at(cur_start, cur_height - 1, target_start, target_height) - } else { - right.node_at(mid, cur_height - 1, target_start, target_height) - } - }, - } - } } fn forward_bag(nodes: T) -> Word @@ -837,6 +811,33 @@ fn proof_sides_for_position(num_leaves: usize, position: usize) -> Option HashIndex { + HashIndex(2 * (position + 1) + 1) +} + +fn parent_hash_index(child: HashIndex) -> HashIndex { + let span = 1usize << (child.0.trailing_zeros() as usize + 2); + HashIndex(child.0 + child.0 % span) +} + +#[cfg(test)] +fn hash_children(parent: HashIndex) -> (HashIndex, HashIndex) { + debug_assert!(parent.0 % 2 == 0); + let span = 1usize << (parent.0.trailing_zeros() as usize - 1); + (HashIndex(parent.0 - 3 * span), HashIndex(parent.0 - span)) +} + +fn node_hash_index(start: usize, height: usize) -> HashIndex { + let mut index = leaf_hash_index(start); + for _ in 0..height { + index = parent_hash_index(index); + } + index +} + /// This is the common currency for all belt geometry: both the prover (over its live mountain /// list) and the verifier (over a shape derived solely from the leaf count) reduce to a slice of /// `ShapeMountain` before computing ranges, positions, and proof handedness. @@ -956,7 +957,16 @@ fn bag_peaks(num_leaves: usize, peaks: &[Word]) -> Word { .map(|range| forward_bag(peaks[range].iter().copied())) .collect::>(); - forward_bag(range_roots) + bind_length(forward_bag(range_roots), num_leaves) +} + +/// Binds the leaf count into a bagged root, yielding the final length-committing commitment. +/// +/// This is the step that closes the `hash_peaks()` shape-omission gap (issue #863): the returned +/// root authenticates `num_leaves`, and hence the belt's entire shape, on its own. +fn bind_length(bagged_root: Word, num_leaves: usize) -> Word { + let length = Word::new([Felt::new_unchecked(num_leaves as u64), ZERO, ZERO, ZERO]); + Poseidon2::merge_in_domain(&[bagged_root, length], BELT_ROOT_DOMAIN) } /// Returns the number of leftmost peaks shared between the `from` and `to` states. @@ -1008,8 +1018,83 @@ fn forward_tree_sides(len: usize, target_idx: usize) -> Vec { mod tests { use alloc::{vec, vec::Vec}; - use super::{BeltSummary, MmrBelt, PartialMmrBelt}; - use crate::merkle::int_to_node; + use super::{ + BeltSummary, HashIndex, MmrBelt, PartialMmrBelt, hash_children, leaf_hash_index, + node_hash_index, parent_hash_index, shape_mountains, shape_ranges, + }; + use crate::{Word, hash::poseidon2::Poseidon2, merkle::int_to_node}; + + fn shape_heights(num_leaves: usize) -> Vec { + shape_mountains(num_leaves).iter().map(|mountain| mountain.height).collect() + } + + fn shape_hash_indices(num_leaves: usize) -> Vec { + shape_mountains(num_leaves) + .iter() + .map(|mountain| node_hash_index(mountain.start, mountain.height).0) + .collect() + } + + fn hash_range(leaves: &[Word], start: usize, height: usize) -> Word { + if height == 0 { + return leaves[start]; + } + + let half = 1usize << (height - 1); + Poseidon2::merge(&[ + hash_range(leaves, start, height - 1), + hash_range(leaves, start + half, height - 1), + ]) + } + + #[test] + fn belt_hash_indices_match_clojure_layout() { + let leaf_indices = (0..6).map(|position| leaf_hash_index(position).0).collect::>(); + assert_eq!(leaf_indices, vec![3, 5, 7, 9, 11, 13]); + + assert_eq!(parent_hash_index(HashIndex(3)), HashIndex(6)); + assert_eq!(parent_hash_index(HashIndex(5)), HashIndex(6)); + assert_eq!(hash_children(HashIndex(6)), (HashIndex(3), HashIndex(5))); + + assert_eq!(parent_hash_index(HashIndex(6)), HashIndex(12)); + assert_eq!(parent_hash_index(HashIndex(10)), HashIndex(12)); + assert_eq!(hash_children(HashIndex(12)), (HashIndex(6), HashIndex(10))); + } + + #[test] + fn belt_shape_hash_indices_match_peak_layout() { + assert_eq!(shape_hash_indices(1), vec![3]); + assert_eq!(shape_hash_indices(2), vec![6]); + assert_eq!(shape_hash_indices(3), vec![6, 7]); + assert_eq!(shape_hash_indices(4), vec![6, 10]); + assert_eq!(shape_hash_indices(5), vec![12, 11]); + assert_eq!(shape_hash_indices(9), vec![12, 20, 19]); + assert_eq!(shape_hash_indices(10), vec![12, 20, 22]); + } + + #[test] + fn belt_hash_array_stores_live_mountain_nodes() { + let mut belt = MmrBelt::new(); + let leaves = (0..64).map(int_to_node).collect::>(); + + for leaf in leaves.iter().copied() { + belt.add(leaf).unwrap(); + } + + for mountain in belt.ordered_mountains() { + for height in 0..=mountain.height { + let width = 1usize << height; + for start in (mountain.start..mountain.start + mountain.size()).step_by(width) { + assert_eq!( + belt.hashes.get(node_hash_index(start, height)), + Some(hash_range(&leaves, start, height)), + "missing hash for node [{start}, {})", + start + width + ); + } + } + } + } #[test] fn belt_lazy_append_height_sequence() { @@ -1147,6 +1232,33 @@ mod tests { assert!(!proof.verify(&summary)); } + #[test] + fn belt_root_binds_leaf_count() { + use super::{bind_length, forward_bag}; + + let num_leaves = 20usize; + let mut belt = MmrBelt::new(); + for idx in 0..num_leaves { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let summary = belt.summary(); + + // Reconstruct the un-bound double-bagging (the raw peak commitment). + let peaks = belt.peaks(); + let shape = shape_mountains(num_leaves); + let range_roots = shape_ranges(&shape) + .into_iter() + .map(|range| forward_bag(peaks[range].iter().copied())) + .collect::>(); + let unbound = forward_bag(range_roots); + + // The published root binds the leaf count: it differs from the raw bagging, and equals the + // raw bagging only once the count is mixed in. So the count cannot be omitted from the root. + assert_ne!(unbound, summary.root()); + assert_eq!(bind_length(unbound, num_leaves), summary.root()); + assert_ne!(bind_length(unbound, num_leaves), bind_length(unbound, num_leaves + 1)); + } + #[test] fn belt_shape_derivation_matches_live_structure_across_pow2() { let mut belt = MmrBelt::new(); @@ -1366,4 +1478,60 @@ mod tests { let partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); assert!(partial.open(3).unwrap().is_none()); } + + #[test] + fn belt_height_sequences_match_paper() { + // Golden mountain-height sequences S_n published in the MMB paper (arXiv:2511.13582): + // §3.1 for n = 9, Figure 5 for n = 10, Figures 5/6 for n = 11, and Figure 7/9 for n = 1337. + // Both the live structure and the O(log n) shape derivation must reproduce them. + let golden: [(usize, &[usize]); 4] = [ + (9, &[2, 2, 0]), + (10, &[2, 2, 1]), + (11, &[3, 1, 0]), + (1337, &[9, 9, 7, 6, 6, 5, 4, 2, 2, 0]), + ]; + + for (num_leaves, expected) in golden { + assert_eq!(shape_heights(num_leaves), expected, "shape S_{num_leaves}"); + + let mut belt = MmrBelt::new(); + for idx in 0..num_leaves { + belt.add(int_to_node(idx as u64)).unwrap(); + } + assert_eq!(belt.mountain_heights(), expected, "live S_{num_leaves}"); + } + } + + #[test] + fn belt_merge_peak_lands_in_last_two_ranges() { + // Lemma 16: when an append performs a merge, the new merge peak sits at the right end of + // its range, in either the rightmost or second-rightmost range. A merge happens on + // the append that brings the count to `n` exactly when `n + 1` is not a power of + // two (Lemma 6.3), and the merge peak then sits at position `nu2(n + 1)` counted + // from the right (Lemma 6.3). + for num_leaves in 2..4096usize { + if (num_leaves + 1).is_power_of_two() { + continue; // merge step skipped on this append + } + + let shape = shape_mountains(num_leaves); + let merge_idx = shape.len() - 1 - (num_leaves + 1).trailing_zeros() as usize; + + let ranges = shape_ranges(&shape); + let range_idx = ranges + .iter() + .position(|range| range.contains(&merge_idx)) + .expect("merge peak must lie in a range"); + + assert_eq!( + ranges[range_idx].end, + merge_idx + 1, + "n={num_leaves}: merge peak must sit at the right end of its range" + ); + assert!( + range_idx + 2 >= ranges.len(), + "n={num_leaves}: merge peak must be in the rightmost or second-rightmost range" + ); + } + } } From 3ed9c0374ce50ea6aa6deac5d1a7155cce5513bf Mon Sep 17 00:00:00 2001 From: krushimir <189111540+krushimir@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:59:36 +0200 Subject: [PATCH 03/10] feat(mmr): bind MMB range and belt layers in proofs --- miden-crypto/src/merkle/mmr/belt.rs | 344 +++++++++++----------------- 1 file changed, 137 insertions(+), 207 deletions(-) diff --git a/miden-crypto/src/merkle/mmr/belt.rs b/miden-crypto/src/merkle/mmr/belt.rs index 90e4a8c81d..f026fd0deb 100644 --- a/miden-crypto/src/merkle/mmr/belt.rs +++ b/miden-crypto/src/merkle/mmr/belt.rs @@ -2,20 +2,40 @@ use alloc::{collections::BTreeMap, vec::Vec}; use core::ops::Range; use super::{Forest, MmrError}; -use crate::{EMPTY_WORD, Felt, Word, ZERO, hash::poseidon2::Poseidon2}; +use crate::{EMPTY_WORD, Felt, Word, hash::poseidon2::Poseidon2}; -/// Domain separator for the final root, which binds the leaf count into the commitment. -/// -/// Because the belt shape is a pure function of the leaf count (Lemma 6), binding the count makes -/// the root commit to the entire structure: it cannot be reinterpreted under a different length. -const BELT_ROOT_DOMAIN: Felt = Felt::new_unchecked(0x4d4d_425f_524f_4f54); // "MMB_ROOT" +/// Range-layer domain base; low bits encode mountain height. +const BELT_RANGE_DOMAIN_BASE: u64 = 0x4d4d_425f_5247_0000; // "MMB_RG" + height +/// Belt-layer domain. +const BELT_BAG_DOMAIN: Felt = Felt::new_unchecked(0x4d4d_425f_4247_4254); // "MMB_BGBT" + +fn range_domain(height: usize) -> Felt { + Felt::new_unchecked(BELT_RANGE_DOMAIN_BASE + height as u64) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FoldDomain { + Mountain, + Range(usize), + Belt, +} + +impl FoldDomain { + fn merge(self, left: Word, right: Word) -> Word { + match self { + FoldDomain::Mountain => Poseidon2::merge(&[left, right]), + FoldDomain::Range(height) => { + Poseidon2::merge_in_domain(&[left, right], range_domain(height)) + }, + FoldDomain::Belt => Poseidon2::merge_in_domain(&[left, right], BELT_BAG_DOMAIN), + } + } +} /// Prototype implementation of the Merkle Mountain Belt construction. /// -/// This type is intentionally reference-oriented: it keeps live mountains in a linked list and -/// re-derives belt ranges, summaries, and proofs from that list while the paper mechanics settle. -/// Appends use stable mountain slots, local links, a mergeable-pair stack, and indexed hash-array -/// storage for committed nodes. Increment proofs are not yet implemented. +/// This reference-oriented implementation uses stable mountain slots, a mergeable-pair stack, and +/// indexed hash-array storage. Increment proofs are not yet implemented. #[derive(Debug, Clone, Default)] pub struct MmrBelt { mountains: Vec>, @@ -72,11 +92,7 @@ impl MmrBelt { self.num_leaves } - /// Returns the ordered mountain peaks: the additive mountain-order summary. - /// - /// These are the mountain roots from left (oldest) to right (newest). Unlike the single - /// double-bagged [`BeltSummary::root`], this list is incremental: after a `k`-increment only - /// its `O(log k)` rightmost entries change (see [`MmrBelt::delta`]). + /// Returns the left-to-right mountain roots. pub fn peaks(&self) -> Vec { self.ordered_mountains() .iter() @@ -91,13 +107,7 @@ impl MmrBelt { } } - /// Returns the delta needed to update a mountain-order summary from `from_num_leaves` leaves to - /// the current state. - /// - /// By Lemma 9 of the MMB paper, the peak lists of two states differ in only `O(log k)` hashes, - /// all at the rightmost end, so the delta carries just the changed tail of the peak list. It - /// also carries the `O(log² k)` within-mountain authentication nodes needed to extend any - /// tracked leaf whose mountain merged, so a [`PartialMmrBelt`] never has to re-track. + /// Returns the delta needed to update a mountain-order summary from `from_num_leaves`. /// /// # Errors /// Returns an error if `from_num_leaves` exceeds the current leaf count. @@ -159,7 +169,7 @@ impl MmrBelt { .iter() .map(|mountain| self.hash_at(mountain.index)) .collect::>(); - nodes.extend(bagging_path_nodes(&peaks, &shape_ranges(&shape), mountain_idx)); + nodes.extend(bagging_path_nodes(&shape, &peaks, &shape_ranges(&shape), mountain_idx)); Ok(BeltProof { position, leaf, nodes }) } @@ -170,17 +180,12 @@ impl MmrBelt { mountain: ShapeMountain, ) -> (Word, Vec) { let leaf = self.node_at(position, 0); - let mut nodes = Vec::with_capacity(mountain.height); - let mut node_start = position; - - for height in 0..mountain.height { - let (sibling_start, parent_start) = sibling_and_parent_start(node_start, height); - nodes.push(BeltProofNode { + let nodes = climb_to_peak(position, 0, mountain.height) + .map(|(side, sibling_start, height)| BeltProofNode { value: self.node_at(sibling_start, height), - side: sibling_side((node_start >> height) & 1 == 0), - }); - node_start = parent_start; - } + side, + }) + .collect(); (leaf, nodes) } @@ -372,10 +377,6 @@ pub struct BeltSummary { impl BeltSummary { /// Builds a summary by double-bagging a mountain-order peak list. /// - /// This lets a client that maintains only the additive peak list (see [`MmrBelt::peaks`]) - /// derive the same `O(1)` commitment a full node publishes, without storing the belt - /// itself. - /// /// # Errors /// Returns an error if the number of peaks does not match the shape implied by `num_leaves`. pub fn from_peaks(num_leaves: usize, peaks: &[Word]) -> Result { @@ -395,8 +396,7 @@ impl BeltSummary { /// Returns the number of leaves authenticated by this summary. /// - /// The authenticated commitment is the pair `(num_leaves, root)`. The root alone is not a - /// length-binding commitment, matching the convention used by the frontier benchmarks. + /// The count lets consumers derive the expected shape and proof handedness. pub fn num_leaves(&self) -> usize { self.num_leaves } @@ -406,21 +406,13 @@ impl BeltSummary { } } -/// An incremental update to a mountain-order summary, carrying only the peaks that changed during a -/// `k`-increment. -/// -/// The unchanged peaks form a prefix of the peak list (the leftmost mountains never participate in -/// a merge during the increment), so only the rightmost `O(log k)` peaks need to be transmitted. A -/// client applies it to its summary with [`MmrBeltDelta::apply`], and to its tracked leaves with -/// [`PartialMmrBelt::apply`], using the carried `merge_auth` nodes. +/// Incremental update to a mountain-order summary. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MmrBeltDelta { from_num_leaves: usize, to_num_leaves: usize, new_tail_peaks: Vec, - /// Within-mountain authentication nodes, keyed by their `(start, height)` coordinate, that let - /// a client extend the path of any tracked leaf whose mountain merged during the - /// increment. + /// Authentication nodes for extending tracked leaves whose mountain merged. merge_auth: BTreeMap<(usize, usize), Word>, } @@ -441,11 +433,7 @@ impl MmrBeltDelta { self.merge_auth.len() } - /// Applies this delta to a client's `old_peaks` (the mountain-order summary at - /// [`Self::from_num_leaves`]), returning the updated peak list at [`Self::to_num_leaves`]. - /// - /// The unchanged prefix length is recomputed from the leaf counts alone, so a client does not - /// trust the producer's split point. + /// Applies this delta to a client's old mountain-order peak list. /// /// # Errors /// Returns an error if `old_peaks` does not match the shape implied by @@ -477,17 +465,10 @@ impl MmrBeltDelta { // PARTIAL MERKLE MOUNTAIN BELT // ================================================================================================ -/// A client-side view of a Merkle Mountain Belt. -/// -/// It stores the mountain-order summary `(num_leaves, peaks)` — enough to derive the `O(1)` -/// commitment locally and to authenticate newly tracked leaves — plus the within-mountain -/// authentication path of a tracked subset of leaves. The bagging layers (range and belt nodes) are -/// not stored: a client holding all mountain peaks rebuilds them locally on demand (Lemma 15 of the -/// MMB paper), which keeps tracked state small and lets every append re-bag for free. +/// Client-side view of a Merkle Mountain Belt. /// -/// A tracked leaf's within-mountain path is invariant as long as its mountain does not merge (the -/// covered leaves are immutable). [`Self::apply`] extends tracked leaves in place when their -/// mountains merge during the increment, using the delta's carried authentication nodes. +/// Stores the mountain-order summary plus within-mountain paths for tracked leaves. Range and belt +/// paths are rebuilt from the local peak list on demand. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PartialMmrBelt { num_leaves: usize, @@ -594,16 +575,12 @@ impl PartialMmrBelt { shape_mountain_for_position(&shape, pos).ok_or(MmrError::PositionNotFound(pos))?; let mut nodes = tracked.within_path.clone(); - nodes.extend(bagging_path_nodes(&self.peaks, &shape_ranges(&shape), mountain_idx)); + nodes.extend(bagging_path_nodes(&shape, &self.peaks, &shape_ranges(&shape), mountain_idx)); Ok(Some(BeltProof { position: pos, leaf: tracked.leaf, nodes })) } - /// Applies an increment delta, advancing the summary and every tracked leaf to the delta's - /// target state. - /// - /// Tracked leaves whose mountain merged during the increment have their within-mountain path - /// extended in place using the delta's authentication nodes, so no re-tracking is ever needed. + /// Applies an increment delta to the summary and tracked paths. /// /// # Errors /// Returns an error if the delta does not originate from the current state, if it does not @@ -623,17 +600,14 @@ impl PartialMmrBelt { .ok_or(MmrError::PositionNotFound(pos))?; let new_mountain = new_shape[mountain_idx]; - for (node_start, sibling_start, height) in + for (side, sibling_start, height) in climb_to_peak(tracked.mountain_start, tracked.mountain_height, new_mountain.height) { let &value = delta .merge_auth .get(&(sibling_start, height)) .ok_or(MmrError::InvalidUpdate)?; - tracked.within_path.push(BeltProofNode { - value, - side: sibling_side((node_start >> height) & 1 == 0), - }); + tracked.within_path.push(BeltProofNode { value, side }); } tracked.mountain_start = new_mountain.start; @@ -665,27 +639,26 @@ impl BeltProof { return false; } - let Some(expected_sides) = proof_sides_for_position(summary.num_leaves, self.position) - else { + let Some(steps) = proof_steps_for_position(summary.num_leaves, self.position) else { return false; }; - if self.nodes.len() != expected_sides.len() - || self - .nodes - .iter() - .zip(expected_sides) - .any(|(node, expected_side)| node.side != expected_side) + if self.nodes.len() != steps.len() + || self.nodes.iter().zip(&steps).any(|(node, (side, _))| node.side != *side) { return false; } - let bagged = self.nodes.iter().fold(self.leaf, |current, node| match node.side { - SiblingSide::Left => Poseidon2::merge(&[node.value, current]), - SiblingSide::Right => Poseidon2::merge(&[current, node.value]), - }); + let root = + self.nodes + .iter() + .zip(&steps) + .fold(self.leaf, |current, (node, (side, domain))| match side { + SiblingSide::Left => domain.merge(node.value, current), + SiblingSide::Right => domain.merge(current, node.value), + }); - bind_length(bagged, summary.num_leaves) == summary.root + root == summary.root } #[cfg(test)] @@ -741,36 +714,8 @@ impl BeltMountain { } } -fn forward_bag(nodes: T) -> Word -where - T: IntoIterator, -{ - let mut iter = nodes.into_iter(); - let Some(first) = iter.next() else { - return EMPTY_WORD; - }; - - iter.fold(first, |left, right| Poseidon2::merge(&[left, right])) -} - -// The target's subtree is the left child at every level it climbs, so its path is the prefix bag -// to its left (one node, if any) followed by each peak to its right. -fn forward_tree_path(nodes: &[Word], target_idx: usize) -> Vec { - debug_assert!(!nodes.is_empty()); - debug_assert!(target_idx < nodes.len()); - - let mut path = Vec::with_capacity(nodes.len() - target_idx); - if target_idx > 0 { - let left = forward_bag(nodes[..target_idx].iter().copied()); - path.push(BeltProofNode { value: left, side: SiblingSide::Left }); - } - for &node in &nodes[target_idx + 1..] { - path.push(BeltProofNode { value: node, side: SiblingSide::Right }); - } - path -} - fn bagging_path_nodes( + shape: &[ShapeMountain], peaks: &[Word], ranges: &[Range], mountain_idx: usize, @@ -781,34 +726,63 @@ fn bagging_path_nodes( .expect("mountain must be part of a range"); let range = ranges[range_idx].clone(); - let mut nodes = forward_tree_path(&peaks[range.clone()], mountain_idx - range.start); + let mut nodes = Vec::new(); + + let prefix = bag_range(&shape[range.start..mountain_idx], &peaks[range.start..mountain_idx]); + nodes.push(BeltProofNode { value: prefix, side: SiblingSide::Left }); + for &peak in &peaks[mountain_idx + 1..range.end] { + nodes.push(BeltProofNode { value: peak, side: SiblingSide::Right }); + } + let range_roots = ranges .iter() - .map(|range| forward_bag(peaks[range.clone()].iter().copied())) + .map(|range| bag_range(&shape[range.clone()], &peaks[range.clone()])) .collect::>(); - nodes.extend(forward_tree_path(&range_roots, range_idx)); + let prefix_belt = bag_belt(&range_roots[..range_idx]); + nodes.push(BeltProofNode { + value: prefix_belt, + side: SiblingSide::Left, + }); + for &root in &range_roots[range_idx + 1..] { + nodes.push(BeltProofNode { value: root, side: SiblingSide::Right }); + } + nodes } -fn proof_sides_for_position(num_leaves: usize, position: usize) -> Option> { +fn proof_steps_for_position( + num_leaves: usize, + position: usize, +) -> Option> { if position >= num_leaves { return None; } - let mountains = shape_mountains(num_leaves); - let mountain_idx = shape_mountain_for_position(&mountains, position)?; - let mountain = mountains[mountain_idx]; - let local_position = position - mountain.start; + let shape = shape_mountains(num_leaves); + let mountain_idx = shape_mountain_for_position(&shape, position)?; + let mountain = shape[mountain_idx]; - let mut sides = balanced_tree_sides(mountain.height, local_position); + let mut steps: Vec<(SiblingSide, FoldDomain)> = + balanced_tree_sides(mountain.height, position - mountain.start) + .into_iter() + .map(|side| (side, FoldDomain::Mountain)) + .collect(); - let ranges = shape_ranges(&mountains); + let ranges = shape_ranges(&shape); let range_idx = ranges.iter().position(|range| range.contains(&mountain_idx))?; let range = ranges[range_idx].clone(); - sides.extend(forward_tree_sides(range.len(), mountain_idx - range.start)); - sides.extend(forward_tree_sides(ranges.len(), range_idx)); - Some(sides) + steps.push((SiblingSide::Left, FoldDomain::Range(mountain.height))); + for mountain in &shape[mountain_idx + 1..range.end] { + steps.push((SiblingSide::Right, FoldDomain::Range(mountain.height))); + } + + steps.push((SiblingSide::Left, FoldDomain::Belt)); + for _ in (range_idx + 1)..ranges.len() { + steps.push((SiblingSide::Right, FoldDomain::Belt)); + } + + Some(steps) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -831,16 +805,10 @@ fn hash_children(parent: HashIndex) -> (HashIndex, HashIndex) { } fn node_hash_index(start: usize, height: usize) -> HashIndex { - let mut index = leaf_hash_index(start); - for _ in 0..height { - index = parent_hash_index(index); - } - index + // Lemma 24 closed form for the node covering `[start, start + 2^height)`. + HashIndex((2 * (start >> height) + 3) << height) } -/// This is the common currency for all belt geometry: both the prover (over its live mountain -/// list) and the verifier (over a shape derived solely from the leaf count) reduce to a slice of -/// `ShapeMountain` before computing ranges, positions, and proof handedness. #[derive(Debug, Clone, Copy)] struct ShapeMountain { start: usize, @@ -863,12 +831,6 @@ fn shape_from_mountains(mountains: &[&BeltMountain]) -> Vec { .collect() } -/// Derives the left-to-right mountain shape directly from the leaf count, in O(log n) time. -/// -/// By Lemma 6 of the MMB paper, if `num_leaves + 1 = (b_t .. b_1 b_0)` in binary then there are -/// `t = floor(log2(num_leaves + 1))` mountains, and the mountain at position `i` (counted from the -/// right, starting at zero) has height `s_i = i + b_i`; the leading bit `b_t` is ignored. This lets -/// a verifier reconstruct the geometry without replaying every append. fn shape_mountains(num_leaves: usize) -> Vec { if num_leaves == 0 { return Vec::new(); @@ -922,29 +884,25 @@ fn shape_range_split_after(mountains: &[ShapeMountain], idx: usize) -> bool { drops_by_two || left_is_right_member_of_mergeable_pair } -// `start` is a multiple of `2^height`, so the parity of `start >> height` decides handedness: even -// is a left child (sibling to the right), odd is a right child (sibling to the left). -fn sibling_and_parent_start(start: usize, height: usize) -> (usize, usize) { +fn sibling_and_parent_start(start: usize, height: usize) -> (SiblingSide, usize, usize) { let span = 1usize << height; if (start >> height) & 1 == 0 { - (start + span, start) + (SiblingSide::Right, start + span, start) } else { - (start - span, start - span) + (SiblingSide::Left, start - span, start - span) } } -// The delta producer and client walk this same sequence to avoid path-extension drift. fn climb_to_peak( start: usize, from_height: usize, to_height: usize, -) -> impl Iterator { +) -> impl Iterator { let mut start = start; (from_height..to_height).map(move |height| { - let node_start = start; - let (sibling_start, parent_start) = sibling_and_parent_start(start, height); + let (side, sibling_start, parent_start) = sibling_and_parent_start(start, height); start = parent_start; - (node_start, sibling_start, height) + (side, sibling_start, height) }) } @@ -954,26 +912,25 @@ fn bag_peaks(num_leaves: usize, peaks: &[Word]) -> Word { let range_roots = shape_ranges(&shape) .into_iter() - .map(|range| forward_bag(peaks[range].iter().copied())) + .map(|range| bag_range(&shape[range.clone()], &peaks[range])) .collect::>(); - bind_length(forward_bag(range_roots), num_leaves) + bag_belt(&range_roots) } -/// Binds the leaf count into a bagged root, yielding the final length-committing commitment. -/// -/// This is the step that closes the `hash_peaks()` shape-omission gap (issue #863): the returned -/// root authenticates `num_leaves`, and hence the belt's entire shape, on its own. -fn bind_length(bagged_root: Word, num_leaves: usize) -> Word { - let length = Word::new([Felt::new_unchecked(num_leaves as u64), ZERO, ZERO, ZERO]); - Poseidon2::merge_in_domain(&[bagged_root, length], BELT_ROOT_DOMAIN) +fn bag_range(mountains: &[ShapeMountain], peaks: &[Word]) -> Word { + debug_assert_eq!(mountains.len(), peaks.len()); + mountains.iter().zip(peaks).fold(EMPTY_WORD, |acc, (mountain, &peak)| { + FoldDomain::Range(mountain.height).merge(acc, peak) + }) +} + +fn bag_belt(range_roots: &[Word]) -> Word { + range_roots + .iter() + .fold(EMPTY_WORD, |acc, &root| FoldDomain::Belt.merge(acc, root)) } -/// Returns the number of leftmost peaks shared between the `from` and `to` states. -/// -/// Two mountains with the same start offset and height cover the same immutable leaf range, so they -/// have identical peak hashes. The shared prefix is therefore the longest run of position-identical -/// mountains, and everything past it is what a [`MmrBeltDelta`] must carry. fn common_peak_prefix_len(from_num_leaves: usize, to_num_leaves: usize) -> usize { let from_shape = shape_mountains(from_num_leaves); let to_shape = shape_mountains(to_num_leaves); @@ -1002,18 +959,6 @@ fn balanced_tree_sides(height: usize, mut local_position: usize) -> Vec Vec { - debug_assert!(len > 0); - debug_assert!(target_idx < len); - - let mut sides = Vec::with_capacity(len - target_idx); - if target_idx > 0 { - sides.push(SiblingSide::Left); - } - sides.resize(sides.len() + (len - 1 - target_idx), SiblingSide::Right); - sides -} - #[cfg(test)] mod tests { use alloc::{vec, vec::Vec}; @@ -1233,30 +1178,21 @@ mod tests { } #[test] - fn belt_root_binds_leaf_count() { - use super::{bind_length, forward_bag}; - - let num_leaves = 20usize; + fn belt_root_binds_shape_and_separates_layers() { let mut belt = MmrBelt::new(); - for idx in 0..num_leaves { - belt.add(int_to_node(idx as u64)).unwrap(); + for idx in 0..2 { + belt.add(int_to_node(idx)).unwrap(); } let summary = belt.summary(); - - // Reconstruct the un-bound double-bagging (the raw peak commitment). let peaks = belt.peaks(); - let shape = shape_mountains(num_leaves); - let range_roots = shape_ranges(&shape) - .into_iter() - .map(|range| forward_bag(peaks[range].iter().copied())) - .collect::>(); - let unbound = forward_bag(range_roots); + assert_eq!(peaks.len(), 1); + assert_ne!(summary.root(), peaks[0], "root must not be transparent to its peak"); - // The published root binds the leaf count: it differs from the raw bagging, and equals the - // raw bagging only once the count is mixed in. So the count cannot be omitted from the root. - assert_ne!(unbound, summary.root()); - assert_eq!(bind_length(unbound, num_leaves), summary.root()); - assert_ne!(bind_length(unbound, num_leaves), bind_length(unbound, num_leaves + 1)); + let mut bigger = MmrBelt::new(); + for idx in 0..3 { + bigger.add(int_to_node(idx)).unwrap(); + } + assert_ne!(bigger.summary().root(), summary.root()); } #[test] @@ -1481,9 +1417,7 @@ mod tests { #[test] fn belt_height_sequences_match_paper() { - // Golden mountain-height sequences S_n published in the MMB paper (arXiv:2511.13582): - // §3.1 for n = 9, Figure 5 for n = 10, Figures 5/6 for n = 11, and Figure 7/9 for n = 1337. - // Both the live structure and the O(log n) shape derivation must reproduce them. + // Golden S_n sequences from arXiv:2511.13582, §3.1 and Figures 5/7/9. let golden: [(usize, &[usize]); 4] = [ (9, &[2, 2, 0]), (10, &[2, 2, 1]), @@ -1504,11 +1438,7 @@ mod tests { #[test] fn belt_merge_peak_lands_in_last_two_ranges() { - // Lemma 16: when an append performs a merge, the new merge peak sits at the right end of - // its range, in either the rightmost or second-rightmost range. A merge happens on - // the append that brings the count to `n` exactly when `n + 1` is not a power of - // two (Lemma 6.3), and the merge peak then sits at position `nu2(n + 1)` counted - // from the right (Lemma 6.3). + // Lemma 16. for num_leaves in 2..4096usize { if (num_leaves + 1).is_power_of_two() { continue; // merge step skipped on this append From a90493bbe0218e1ed8010c84b900479db60e012b Mon Sep 17 00:00:00 2001 From: krushimir <189111540+krushimir@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:37:19 +0200 Subject: [PATCH 04/10] add paper-aligned MMR belt append prototype --- miden-crypto/benches/mmr_belt.rs | 16 +- miden-crypto/src/merkle/mmr/belt.rs | 247 +++++++++++++++++++++------- 2 files changed, 193 insertions(+), 70 deletions(-) diff --git a/miden-crypto/benches/mmr_belt.rs b/miden-crypto/benches/mmr_belt.rs index 99ea7e98bf..bf3440eac2 100644 --- a/miden-crypto/benches/mmr_belt.rs +++ b/miden-crypto/benches/mmr_belt.rs @@ -97,24 +97,24 @@ fn bench_mmr_belt_append(c: &mut Criterion) { let next_leaf = generate_word_pattern(size as u64, WordPattern::Sequential); group.bench_with_input(BenchmarkId::new("current-mmr", size), &size, |b, _| { - b.iter_batched( + b.iter_batched_ref( || data.mmr.clone(), - |mut mmr| { + |mmr| { mmr.add(hint::black_box(next_leaf)).unwrap(); - hint::black_box(mmr); + hint::black_box(mmr.forest().num_leaves()); }, - BatchSize::SmallInput, + BatchSize::LargeInput, ); }); group.bench_with_input(BenchmarkId::new("belt-prototype", size), &size, |b, _| { - b.iter_batched( + b.iter_batched_ref( || data.belt.clone(), - |mut belt| { + |belt| { belt.add(hint::black_box(next_leaf)).unwrap(); - hint::black_box(belt); + hint::black_box(belt.num_leaves()); }, - BatchSize::SmallInput, + BatchSize::LargeInput, ); }); diff --git a/miden-crypto/src/merkle/mmr/belt.rs b/miden-crypto/src/merkle/mmr/belt.rs index f026fd0deb..10b1949abc 100644 --- a/miden-crypto/src/merkle/mmr/belt.rs +++ b/miden-crypto/src/merkle/mmr/belt.rs @@ -2,21 +2,12 @@ use alloc::{collections::BTreeMap, vec::Vec}; use core::ops::Range; use super::{Forest, MmrError}; -use crate::{EMPTY_WORD, Felt, Word, hash::poseidon2::Poseidon2}; - -/// Range-layer domain base; low bits encode mountain height. -const BELT_RANGE_DOMAIN_BASE: u64 = 0x4d4d_425f_5247_0000; // "MMB_RG" + height -/// Belt-layer domain. -const BELT_BAG_DOMAIN: Felt = Felt::new_unchecked(0x4d4d_425f_4247_4254); // "MMB_BGBT" - -fn range_domain(height: usize) -> Felt { - Felt::new_unchecked(BELT_RANGE_DOMAIN_BASE + height as u64) -} +use crate::{EMPTY_WORD, Word, hash::poseidon2::Poseidon2}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FoldDomain { Mountain, - Range(usize), + Range, Belt, } @@ -24,10 +15,8 @@ impl FoldDomain { fn merge(self, left: Word, right: Word) -> Word { match self { FoldDomain::Mountain => Poseidon2::merge(&[left, right]), - FoldDomain::Range(height) => { - Poseidon2::merge_in_domain(&[left, right], range_domain(height)) - }, - FoldDomain::Belt => Poseidon2::merge_in_domain(&[left, right], BELT_BAG_DOMAIN), + FoldDomain::Range => Poseidon2::merge(&[left, right]), + FoldDomain::Belt => Poseidon2::merge(&[left, right]), } } } @@ -43,8 +32,7 @@ pub struct MmrBelt { free_mountain_slots: Vec, head: Option, tail: Option, - mergeable_pairs: Vec, - next_slot_generation: u64, + rightmost_mergeable: Option, num_leaves: usize, } @@ -70,7 +58,7 @@ impl MmrBelt { self.track_mergeable_pair(prev_idx, new_idx); } - if let Some(right_idx) = self.pop_mergeable_pair() { + if let Some(right_idx) = self.rightmost_mergeable { self.merge_pair(right_idx); Ok(1) } else { @@ -88,6 +76,14 @@ impl MmrBelt { self.mountains.len() } + #[cfg(test)] + fn rightmost_mergeable_pair_for_testing(&self) -> Option<(usize, usize)> { + let right_idx = self.rightmost_mergeable?; + let right = self.mountain_slot(right_idx); + let left_idx = right.prev?; + Some((self.mountain_slot(left_idx).mountain.start, right.mountain.start)) + } + pub fn num_leaves(&self) -> usize { self.num_leaves } @@ -201,13 +197,13 @@ impl MmrBelt { } fn push_mountain(&mut self, mountain: BeltMountain) -> usize { - let generation = self.next_slot_generation; - self.next_slot_generation += 1; let slot = BeltMountainSlot { mountain, prev: self.tail, next: None, - generation, + mergeable_prev: None, + mergeable_next: None, + in_mergeable_list: false, }; let idx = if let Some(idx) = self.free_mountain_slots.pop() { @@ -236,7 +232,14 @@ impl MmrBelt { .expect("right member of mergeable pair must have left neighbor"); let right_next = self.mountain_slot(right_idx).next; let left_prev = self.mountain_slot(left_idx).prev; - let left_generation = self.mountain_slot(left_idx).generation; + let insert_after = if self.mountain_slot(left_idx).in_mergeable_list { + self.mountain_slot(left_idx).mergeable_prev + } else { + self.mountain_slot(right_idx).mergeable_prev + }; + + self.untrack_mergeable_pair(right_idx); + self.untrack_mergeable_pair(left_idx); let right = self.mountains[right_idx] .take() @@ -255,7 +258,9 @@ impl MmrBelt { mountain: merged, prev: left_prev, next: right_next, - generation: left_generation, + mergeable_prev: None, + mergeable_next: None, + in_mergeable_list: false, }); if let Some(next_idx) = right_next { @@ -266,41 +271,64 @@ impl MmrBelt { self.free_mountain_slots.push(right_idx); if let Some(prev_idx) = left_prev { - self.track_mergeable_pair(prev_idx, left_idx); + self.track_mergeable_pair_after(insert_after, prev_idx, left_idx); } if let Some(next_idx) = right_next { - self.track_mergeable_pair(left_idx, next_idx); + self.track_mergeable_pair_after(self.rightmost_mergeable, left_idx, next_idx); } } fn track_mergeable_pair(&mut self, left_idx: usize, right_idx: usize) { - if self.is_mergeable_pair(left_idx, right_idx) { - self.mergeable_pairs.push(MergeablePair { - right_idx, - right_generation: self.mountain_slot(right_idx).generation, - }); + self.track_mergeable_pair_after(self.rightmost_mergeable, left_idx, right_idx); + } + + fn track_mergeable_pair_after( + &mut self, + prev_pair: Option, + left_idx: usize, + right_idx: usize, + ) { + if !self.is_mergeable_pair(left_idx, right_idx) + || self.mountain_slot(right_idx).in_mergeable_list + { + return; } + + if let Some(prev_idx) = prev_pair { + self.mountain_slot_mut(prev_idx).mergeable_next = Some(right_idx); + } + + let slot = self.mountain_slot_mut(right_idx); + slot.mergeable_prev = prev_pair; + slot.mergeable_next = None; + slot.in_mergeable_list = true; + self.rightmost_mergeable = Some(right_idx); } - fn pop_mergeable_pair(&mut self) -> Option { - while let Some(pair) = self.mergeable_pairs.pop() { - let right_idx = pair.right_idx; - let Some(right_slot) = self.mountains.get(right_idx).and_then(Option::as_ref) else { - continue; - }; - if right_slot.generation != pair.right_generation { - continue; - } - let Some(left_idx) = right_slot.prev else { - continue; - }; + fn untrack_mergeable_pair(&mut self, right_idx: usize) { + let Some(slot) = self.mountains.get(right_idx).and_then(Option::as_ref) else { + return; + }; + if !slot.in_mergeable_list { + return; + } - if self.is_mergeable_pair(left_idx, right_idx) { - return Some(right_idx); - } + let prev = slot.mergeable_prev; + let next = slot.mergeable_next; + + if let Some(prev_idx) = prev { + self.mountain_slot_mut(prev_idx).mergeable_next = next; + } + if let Some(next_idx) = next { + self.mountain_slot_mut(next_idx).mergeable_prev = prev; + } else { + self.rightmost_mergeable = prev; } - None + let slot = self.mountain_slot_mut(right_idx); + slot.mergeable_prev = None; + slot.mergeable_next = None; + slot.in_mergeable_list = false; } fn is_mergeable_pair(&self, left_idx: usize, right_idx: usize) -> bool { @@ -341,13 +369,9 @@ struct BeltMountainSlot { mountain: BeltMountain, prev: Option, next: Option, - generation: u64, -} - -#[derive(Debug, Clone, Copy)] -struct MergeablePair { - right_idx: usize, - right_generation: u64, + mergeable_prev: Option, + mergeable_next: Option, + in_mergeable_list: bool, } #[derive(Debug, Clone, Default)] @@ -772,9 +796,9 @@ fn proof_steps_for_position( let range_idx = ranges.iter().position(|range| range.contains(&mountain_idx))?; let range = ranges[range_idx].clone(); - steps.push((SiblingSide::Left, FoldDomain::Range(mountain.height))); - for mountain in &shape[mountain_idx + 1..range.end] { - steps.push((SiblingSide::Right, FoldDomain::Range(mountain.height))); + steps.push((SiblingSide::Left, FoldDomain::Range)); + for _ in &shape[mountain_idx + 1..range.end] { + steps.push((SiblingSide::Right, FoldDomain::Range)); } steps.push((SiblingSide::Left, FoldDomain::Belt)); @@ -920,9 +944,10 @@ fn bag_peaks(num_leaves: usize, peaks: &[Word]) -> Word { fn bag_range(mountains: &[ShapeMountain], peaks: &[Word]) -> Word { debug_assert_eq!(mountains.len(), peaks.len()); - mountains.iter().zip(peaks).fold(EMPTY_WORD, |acc, (mountain, &peak)| { - FoldDomain::Range(mountain.height).merge(acc, peak) - }) + mountains + .iter() + .zip(peaks) + .fold(EMPTY_WORD, |acc, (_, &peak)| FoldDomain::Range.merge(acc, peak)) } fn bag_belt(range_roots: &[Word]) -> Word { @@ -964,10 +989,10 @@ mod tests { use alloc::{vec, vec::Vec}; use super::{ - BeltSummary, HashIndex, MmrBelt, PartialMmrBelt, hash_children, leaf_hash_index, + BeltSummary, HashIndex, MmrBelt, PartialMmrBelt, bag_range, hash_children, leaf_hash_index, node_hash_index, parent_hash_index, shape_mountains, shape_ranges, }; - use crate::{Word, hash::poseidon2::Poseidon2, merkle::int_to_node}; + use crate::{EMPTY_WORD, Word, hash::poseidon2::Poseidon2, merkle::int_to_node}; fn shape_heights(num_leaves: usize) -> Vec { shape_mountains(num_leaves).iter().map(|mountain| mountain.height).collect() @@ -1089,6 +1114,24 @@ mod tests { } } + #[test] + fn belt_tracks_rightmost_mergeable_pair_without_stale_stack() { + let mut belt = MmrBelt::new(); + + for idx in 0..512 { + belt.add(int_to_node(idx)).unwrap(); + + let mountains = belt.ordered_mountains(); + let expected = mountains + .windows(2) + .rev() + .find(|pair| pair[0].height == pair[1].height) + .map(|pair| (pair[0].start, pair[1].start)); + + assert_eq!(belt.rightmost_mergeable_pair_for_testing(), expected, "after {idx}"); + } + } + #[test] fn belt_range_splits_follow_mmb_rules() { let mut belt = MmrBelt::new(); @@ -1178,7 +1221,7 @@ mod tests { } #[test] - fn belt_root_binds_shape_and_separates_layers() { + fn belt_root_binds_shape_without_belt_domain_separation() { let mut belt = MmrBelt::new(); for idx in 0..2 { belt.add(int_to_node(idx)).unwrap(); @@ -1195,6 +1238,46 @@ mod tests { assert_ne!(bigger.summary().root(), summary.root()); } + #[test] + fn belt_second_bagging_uses_plain_merkle_merge() { + let mut belt = MmrBelt::new(); + for idx in 0..5 { + belt.add(int_to_node(idx)).unwrap(); + } + + let shape = shape_mountains(belt.num_leaves()); + let peaks = belt.peaks(); + let range_roots = shape_ranges(&shape) + .into_iter() + .map(|range| bag_range(&shape[range.clone()], &peaks[range])) + .collect::>(); + assert!(range_roots.len() > 1); + + let expected = + range_roots.iter().fold(EMPTY_WORD, |acc, &root| Poseidon2::merge(&[acc, root])); + + assert_eq!(belt.summary().root(), expected); + } + + #[test] + fn belt_range_bagging_uses_plain_merkle_merge() { + let mut belt = MmrBelt::new(); + for idx in 0..9 { + belt.add(int_to_node(idx)).unwrap(); + } + + let shape = shape_mountains(belt.num_leaves()); + let peaks = belt.peaks(); + let first_range = shape_ranges(&shape).into_iter().next().unwrap(); + assert!(first_range.len() > 1); + + let expected = peaks[first_range.clone()] + .iter() + .fold(EMPTY_WORD, |acc, &peak| Poseidon2::merge(&[acc, peak])); + + assert_eq!(bag_range(&shape[first_range.clone()], &peaks[first_range]), expected); + } + #[test] fn belt_shape_derivation_matches_live_structure_across_pow2() { let mut belt = MmrBelt::new(); @@ -1386,6 +1469,46 @@ mod tests { } } + #[test] + fn partial_belt_protocol_model_resyncs_after_offline_increment() { + let from = 128usize; + let to = 191usize; + let leaves = (0..to as u64).map(int_to_node).collect::>(); + + let mut full_node = MmrBelt::new(); + for &leaf in &leaves[..from] { + full_node.add(leaf).unwrap(); + } + + let mut client = + PartialMmrBelt::from_peaks(full_node.num_leaves(), full_node.peaks()).unwrap(); + for &position in &[0usize, 1, 7, 63, 64, 100, 127] { + client.track(&full_node.open(position).unwrap()).unwrap(); + } + assert_eq!(client.summary(), full_node.summary()); + + for &leaf in &leaves[from..to] { + full_node.add(leaf).unwrap(); + } + let server_delta = full_node.delta(from).unwrap(); + + assert_ne!(client.summary(), full_node.summary()); + client.apply(&server_delta).unwrap(); + assert_eq!(client.summary(), full_node.summary()); + + for &position in &[0usize, 1, 7, 63, 64, 100, 127] { + let client_proof = client.open(position).unwrap().unwrap(); + assert!(client_proof.verify(&client.summary())); + assert_eq!(client_proof, full_node.open(position).unwrap()); + assert_eq!(client.get(position), Some(leaves[position])); + } + + let newest_position = to - 1; + client.track(&full_node.open(newest_position).unwrap()).unwrap(); + assert_eq!(client.get(newest_position), Some(leaves[newest_position])); + assert!(client.open(newest_position).unwrap().unwrap().verify(&client.summary())); + } + #[test] fn partial_belt_delta_merge_auth_is_polylogarithmic() { let mut belt = MmrBelt::new(); From 89690a9c30027d512dac06909e17655096b4fbcd Mon Sep 17 00:00:00 2001 From: krushimir <189111540+krushimir@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:11:12 +0200 Subject: [PATCH 05/10] feat(mmr): add MMB partial sync and benchmarks Add verified belt summary deltas, PartialMmrBelt client state, live bagging maintenance, and expanded MMR/frontier/MMB benchmark coverage. Keep the code isolated in the experimental belt module while preserving the existing MMR API surface. --- miden-crypto/benches/mmr_belt.rs | 137 +- miden-crypto/src/merkle/mmr/belt.rs | 1590 ------------------- miden-crypto/src/merkle/mmr/belt/bagging.rs | 612 +++++++ miden-crypto/src/merkle/mmr/belt/delta.rs | 315 ++++ miden-crypto/src/merkle/mmr/belt/mod.rs | 591 +++++++ miden-crypto/src/merkle/mmr/belt/proof.rs | 143 ++ miden-crypto/src/merkle/mmr/belt/shape.rs | 302 ++++ miden-crypto/src/merkle/mmr/belt/tests.rs | 1302 +++++++++++++++ 8 files changed, 3394 insertions(+), 1598 deletions(-) delete mode 100644 miden-crypto/src/merkle/mmr/belt.rs create mode 100644 miden-crypto/src/merkle/mmr/belt/bagging.rs create mode 100644 miden-crypto/src/merkle/mmr/belt/delta.rs create mode 100644 miden-crypto/src/merkle/mmr/belt/mod.rs create mode 100644 miden-crypto/src/merkle/mmr/belt/proof.rs create mode 100644 miden-crypto/src/merkle/mmr/belt/shape.rs create mode 100644 miden-crypto/src/merkle/mmr/belt/tests.rs diff --git a/miden-crypto/benches/mmr_belt.rs b/miden-crypto/benches/mmr_belt.rs index bf3440eac2..82f14c3c6c 100644 --- a/miden-crypto/benches/mmr_belt.rs +++ b/miden-crypto/benches/mmr_belt.rs @@ -1,16 +1,10 @@ -//! MMR vs experimental Merkle Mountain Belt benchmarks. -//! -//! The belt implementation benchmarked here is a reference prototype behind the `internal` -//! feature. It keeps in-memory hash-array state to derive summaries/proofs, so these numbers -//! should be read as construction-comparison data rather than production storage performance. - use std::{hint, time::Duration}; use criterion::{BatchSize, BenchmarkId, Criterion, criterion_group, criterion_main}; use miden_crypto::{ Word, merkle::mmr::{ - Mmr, MmrPeaks, + MerkleFrontier, Mmr, MmrPeaks, belt::{BeltSummary, MmrBelt}, }, }; @@ -29,6 +23,7 @@ struct MmrBeltBenchData { leaves: Vec, mmr: Mmr, peaks: MmrPeaks, + frontier: MerkleFrontier, belt: MmrBelt, belt_summary: BeltSummary, } @@ -38,6 +33,7 @@ impl MmrBeltBenchData { let leaves = generate_words_pattern(size, WordPattern::Sequential); let mmr = Mmr::try_from_iter(leaves.iter().copied()).unwrap(); let peaks = mmr.peaks(); + let frontier = mmr.frontier(); let mut belt = MmrBelt::new(); for leaf in leaves.iter().copied() { @@ -45,7 +41,14 @@ impl MmrBeltBenchData { } let belt_summary = belt.summary(); - Self { leaves, mmr, peaks, belt, belt_summary } + Self { + leaves, + mmr, + peaks, + frontier, + belt, + belt_summary, + } } } @@ -55,6 +58,11 @@ fn append_sequence(start: usize) -> Vec { .collect() } +fn rebuilt_belt_summary(belt: &MmrBelt) -> BeltSummary { + let roots = belt.peaks(); + BeltSummary::from_roots(belt.num_leaves(), &roots).unwrap() +} + fn bench_mmr_belt_build(c: &mut Criterion) { let mut group = c.benchmark_group("mmr-belt-build"); configure_group(&mut group); @@ -83,6 +91,20 @@ fn bench_mmr_belt_build(c: &mut Criterion) { BatchSize::SmallInput, ); }); + + group.bench_with_input(BenchmarkId::new("belt-lazy-bagging", size), &size, |b, &size| { + b.iter_batched( + || generate_words_pattern(size, WordPattern::Sequential), + |leaves| { + let mut belt = MmrBelt::new(); + for leaf in leaves { + belt.add_without_bagging_for_benchmark(leaf).unwrap(); + } + hint::black_box(belt); + }, + BatchSize::SmallInput, + ); + }); } group.finish(); @@ -107,6 +129,17 @@ fn bench_mmr_belt_append(c: &mut Criterion) { ); }); + group.bench_with_input(BenchmarkId::new("frontier", size), &size, |b, _| { + b.iter_batched_ref( + || data.frontier.clone(), + |frontier| { + frontier.append(hint::black_box(next_leaf)).unwrap(); + hint::black_box(frontier.num_leaves()); + }, + BatchSize::LargeInput, + ); + }); + group.bench_with_input(BenchmarkId::new("belt-prototype", size), &size, |b, _| { b.iter_batched_ref( || data.belt.clone(), @@ -118,6 +151,17 @@ fn bench_mmr_belt_append(c: &mut Criterion) { ); }); + group.bench_with_input(BenchmarkId::new("belt-lazy-bagging", size), &size, |b, _| { + b.iter_batched_ref( + || data.belt.clone(), + |belt| { + belt.add_without_bagging_for_benchmark(hint::black_box(next_leaf)).unwrap(); + hint::black_box(belt.num_leaves()); + }, + BatchSize::LargeInput, + ); + }); + group.bench_with_input( BenchmarkId::new("current-mmr-and-commitment", size), &size, @@ -133,6 +177,17 @@ fn bench_mmr_belt_append(c: &mut Criterion) { }, ); + group.bench_with_input(BenchmarkId::new("frontier-and-root", size), &size, |b, _| { + b.iter_batched( + || data.frontier.clone(), + |mut frontier| { + frontier.append(hint::black_box(next_leaf)).unwrap(); + hint::black_box(frontier.root()); + }, + BatchSize::SmallInput, + ); + }); + group.bench_with_input( BenchmarkId::new("belt-prototype-and-summary", size), &size, @@ -147,6 +202,47 @@ fn bench_mmr_belt_append(c: &mut Criterion) { ); }, ); + + group.bench_with_input( + BenchmarkId::new("belt-lazy-bagging-and-rebuilt-summary", size), + &size, + |b, _| { + b.iter_batched( + || data.belt.clone(), + |mut belt| { + belt.add_without_bagging_for_benchmark(hint::black_box(next_leaf)).unwrap(); + hint::black_box(rebuilt_belt_summary(&belt)); + }, + BatchSize::SmallInput, + ); + }, + ); + + group.bench_with_input(BenchmarkId::new("belt-prototype-and-root", size), &size, |b, _| { + b.iter_batched( + || data.belt.clone(), + |mut belt| { + belt.add(hint::black_box(next_leaf)).unwrap(); + hint::black_box(belt.commitment_root()); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_with_input( + BenchmarkId::new("belt-lazy-bagging-and-rebuilt-root", size), + &size, + |b, _| { + b.iter_batched( + || data.belt.clone(), + |mut belt| { + belt.add_without_bagging_for_benchmark(hint::black_box(next_leaf)).unwrap(); + hint::black_box(rebuilt_belt_summary(&belt).commitment_root()); + }, + BatchSize::SmallInput, + ); + }, + ); } group.finish(); @@ -186,6 +282,19 @@ fn bench_mmr_belt_append_sequence(c: &mut Criterion) { BatchSize::LargeInput, ); }); + + group.bench_with_input(BenchmarkId::new("belt-lazy-bagging", size), &size, |b, _| { + b.iter_batched_ref( + || (data.belt.clone(), leaves.clone()), + |(belt, leaves)| { + for leaf in leaves.iter().copied() { + belt.add_without_bagging_for_benchmark(hint::black_box(leaf)).unwrap(); + } + hint::black_box(belt); + }, + BatchSize::LargeInput, + ); + }); } group.finish(); @@ -204,11 +313,23 @@ fn bench_mmr_belt_commitment(c: &mut Criterion) { }); }); + group.bench_with_input(BenchmarkId::new("frontier-root", size), &size, |b, _| { + b.iter(|| { + hint::black_box(data.frontier.root()); + }); + }); + group.bench_with_input(BenchmarkId::new("belt-summary", size), &size, |b, _| { b.iter(|| { hint::black_box(data.belt.summary()); }); }); + + group.bench_with_input(BenchmarkId::new("belt-commitment-root", size), &size, |b, _| { + b.iter(|| { + hint::black_box(data.belt.commitment_root()); + }); + }); } group.finish(); diff --git a/miden-crypto/src/merkle/mmr/belt.rs b/miden-crypto/src/merkle/mmr/belt.rs deleted file mode 100644 index 10b1949abc..0000000000 --- a/miden-crypto/src/merkle/mmr/belt.rs +++ /dev/null @@ -1,1590 +0,0 @@ -use alloc::{collections::BTreeMap, vec::Vec}; -use core::ops::Range; - -use super::{Forest, MmrError}; -use crate::{EMPTY_WORD, Word, hash::poseidon2::Poseidon2}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum FoldDomain { - Mountain, - Range, - Belt, -} - -impl FoldDomain { - fn merge(self, left: Word, right: Word) -> Word { - match self { - FoldDomain::Mountain => Poseidon2::merge(&[left, right]), - FoldDomain::Range => Poseidon2::merge(&[left, right]), - FoldDomain::Belt => Poseidon2::merge(&[left, right]), - } - } -} - -/// Prototype implementation of the Merkle Mountain Belt construction. -/// -/// This reference-oriented implementation uses stable mountain slots, a mergeable-pair stack, and -/// indexed hash-array storage. Increment proofs are not yet implemented. -#[derive(Debug, Clone, Default)] -pub struct MmrBelt { - mountains: Vec>, - hashes: BeltHashArray, - free_mountain_slots: Vec, - head: Option, - tail: Option, - rightmost_mergeable: Option, - num_leaves: usize, -} - -impl MmrBelt { - pub fn new() -> Self { - Self::default() - } - - pub fn add(&mut self, leaf: Word) -> Result { - if self.num_leaves >= Forest::MAX_LEAVES { - return Err(MmrError::ForestSizeExceeded { - requested: self.num_leaves.saturating_add(1), - max: Forest::MAX_LEAVES, - }); - } - - let leaf_index = leaf_hash_index(self.num_leaves); - self.hashes.set(leaf_index, leaf); - - let new_idx = self.push_mountain(BeltMountain::new(self.num_leaves, leaf_index)); - self.num_leaves += 1; - if let Some(prev_idx) = self.mountain_slot(new_idx).prev { - self.track_mergeable_pair(prev_idx, new_idx); - } - - if let Some(right_idx) = self.rightmost_mergeable { - self.merge_pair(right_idx); - Ok(1) - } else { - Ok(0) - } - } - - #[cfg(test)] - fn mountain_heights(&self) -> Vec { - self.ordered_mountains().iter().map(|mountain| mountain.height).collect() - } - - #[cfg(test)] - fn storage_slots_for_testing(&self) -> usize { - self.mountains.len() - } - - #[cfg(test)] - fn rightmost_mergeable_pair_for_testing(&self) -> Option<(usize, usize)> { - let right_idx = self.rightmost_mergeable?; - let right = self.mountain_slot(right_idx); - let left_idx = right.prev?; - Some((self.mountain_slot(left_idx).mountain.start, right.mountain.start)) - } - - pub fn num_leaves(&self) -> usize { - self.num_leaves - } - - /// Returns the left-to-right mountain roots. - pub fn peaks(&self) -> Vec { - self.ordered_mountains() - .iter() - .map(|mountain| self.hash_at(mountain.index)) - .collect() - } - - pub fn summary(&self) -> BeltSummary { - BeltSummary { - num_leaves: self.num_leaves, - root: bag_peaks(self.num_leaves, &self.peaks()), - } - } - - /// Returns the delta needed to update a mountain-order summary from `from_num_leaves`. - /// - /// # Errors - /// Returns an error if `from_num_leaves` exceeds the current leaf count. - pub fn delta(&self, from_num_leaves: usize) -> Result { - if from_num_leaves > self.num_leaves { - return Err(MmrError::ForestOutOfBounds(from_num_leaves, self.num_leaves)); - } - - let common = common_peak_prefix_len(from_num_leaves, self.num_leaves); - let peaks = self.peaks(); - - let from_shape = shape_mountains(from_num_leaves); - let to_shape = shape_mountains(self.num_leaves); - let mut merge_auth = BTreeMap::new(); - - for absorbed in &from_shape[common..] { - let to_idx = shape_mountain_for_position(&to_shape, absorbed.start) - .expect("absorbed leaves still exist in the new state"); - let to_height = to_shape[to_idx].height; - - for (_, sibling_start, height) in - climb_to_peak(absorbed.start, absorbed.height, to_height) - { - merge_auth - .entry((sibling_start, height)) - .or_insert_with(|| self.node_at(sibling_start, height)); - } - } - - Ok(MmrBeltDelta { - from_num_leaves, - to_num_leaves: self.num_leaves, - new_tail_peaks: peaks[common..].to_vec(), - merge_auth, - }) - } - - fn node_at(&self, start: usize, height: usize) -> Word { - self.hash_at(node_hash_index(start, height)) - } - - fn hash_at(&self, index: HashIndex) -> Word { - self.hashes.get(index).expect("hash must be present in storage") - } - - pub fn open(&self, position: usize) -> Result { - if position >= self.num_leaves { - return Err(MmrError::PositionNotFound(position)); - } - - let mountains = self.ordered_mountains(); - let shape = shape_from_mountains(&mountains); - let mountain_idx = shape_mountain_for_position(&shape, position) - .ok_or(MmrError::PositionNotFound(position))?; - let mountain = shape[mountain_idx]; - let (leaf, mut nodes) = self.open_within_mountain(position, mountain); - - let peaks = mountains - .iter() - .map(|mountain| self.hash_at(mountain.index)) - .collect::>(); - nodes.extend(bagging_path_nodes(&shape, &peaks, &shape_ranges(&shape), mountain_idx)); - - Ok(BeltProof { position, leaf, nodes }) - } - - fn open_within_mountain( - &self, - position: usize, - mountain: ShapeMountain, - ) -> (Word, Vec) { - let leaf = self.node_at(position, 0); - let nodes = climb_to_peak(position, 0, mountain.height) - .map(|(side, sibling_start, height)| BeltProofNode { - value: self.node_at(sibling_start, height), - side, - }) - .collect(); - - (leaf, nodes) - } - - #[cfg(test)] - fn range_heights(&self) -> Vec> { - let mountains = self.ordered_mountains(); - let shape = shape_from_mountains(&mountains); - shape_ranges(&shape) - .into_iter() - .map(|range| shape[range].iter().map(|mountain| mountain.height).collect()) - .collect() - } - - fn push_mountain(&mut self, mountain: BeltMountain) -> usize { - let slot = BeltMountainSlot { - mountain, - prev: self.tail, - next: None, - mergeable_prev: None, - mergeable_next: None, - in_mergeable_list: false, - }; - - let idx = if let Some(idx) = self.free_mountain_slots.pop() { - self.mountains[idx] = Some(slot); - idx - } else { - let idx = self.mountains.len(); - self.mountains.push(Some(slot)); - idx - }; - - if let Some(tail_idx) = self.tail { - self.mountain_slot_mut(tail_idx).next = Some(idx); - } else { - self.head = Some(idx); - } - self.tail = Some(idx); - - idx - } - - fn merge_pair(&mut self, right_idx: usize) { - let left_idx = self - .mountain_slot(right_idx) - .prev - .expect("right member of mergeable pair must have left neighbor"); - let right_next = self.mountain_slot(right_idx).next; - let left_prev = self.mountain_slot(left_idx).prev; - let insert_after = if self.mountain_slot(left_idx).in_mergeable_list { - self.mountain_slot(left_idx).mergeable_prev - } else { - self.mountain_slot(right_idx).mergeable_prev - }; - - self.untrack_mergeable_pair(right_idx); - self.untrack_mergeable_pair(left_idx); - - let right = self.mountains[right_idx] - .take() - .expect("right member of mergeable pair must be active"); - let left = self.mountains[left_idx] - .take() - .expect("left member of mergeable pair must be active"); - let root = Poseidon2::merge(&[ - self.hash_at(left.mountain.index), - self.hash_at(right.mountain.index), - ]); - let merged = left.mountain.merge(right.mountain); - self.hashes.set(merged.index, root); - - self.mountains[left_idx] = Some(BeltMountainSlot { - mountain: merged, - prev: left_prev, - next: right_next, - mergeable_prev: None, - mergeable_next: None, - in_mergeable_list: false, - }); - - if let Some(next_idx) = right_next { - self.mountain_slot_mut(next_idx).prev = Some(left_idx); - } else { - self.tail = Some(left_idx); - } - self.free_mountain_slots.push(right_idx); - - if let Some(prev_idx) = left_prev { - self.track_mergeable_pair_after(insert_after, prev_idx, left_idx); - } - if let Some(next_idx) = right_next { - self.track_mergeable_pair_after(self.rightmost_mergeable, left_idx, next_idx); - } - } - - fn track_mergeable_pair(&mut self, left_idx: usize, right_idx: usize) { - self.track_mergeable_pair_after(self.rightmost_mergeable, left_idx, right_idx); - } - - fn track_mergeable_pair_after( - &mut self, - prev_pair: Option, - left_idx: usize, - right_idx: usize, - ) { - if !self.is_mergeable_pair(left_idx, right_idx) - || self.mountain_slot(right_idx).in_mergeable_list - { - return; - } - - if let Some(prev_idx) = prev_pair { - self.mountain_slot_mut(prev_idx).mergeable_next = Some(right_idx); - } - - let slot = self.mountain_slot_mut(right_idx); - slot.mergeable_prev = prev_pair; - slot.mergeable_next = None; - slot.in_mergeable_list = true; - self.rightmost_mergeable = Some(right_idx); - } - - fn untrack_mergeable_pair(&mut self, right_idx: usize) { - let Some(slot) = self.mountains.get(right_idx).and_then(Option::as_ref) else { - return; - }; - if !slot.in_mergeable_list { - return; - } - - let prev = slot.mergeable_prev; - let next = slot.mergeable_next; - - if let Some(prev_idx) = prev { - self.mountain_slot_mut(prev_idx).mergeable_next = next; - } - if let Some(next_idx) = next { - self.mountain_slot_mut(next_idx).mergeable_prev = prev; - } else { - self.rightmost_mergeable = prev; - } - - let slot = self.mountain_slot_mut(right_idx); - slot.mergeable_prev = None; - slot.mergeable_next = None; - slot.in_mergeable_list = false; - } - - fn is_mergeable_pair(&self, left_idx: usize, right_idx: usize) -> bool { - let Some(left_slot) = self.mountains.get(left_idx).and_then(Option::as_ref) else { - return false; - }; - let Some(right_slot) = self.mountains.get(right_idx).and_then(Option::as_ref) else { - return false; - }; - - left_slot.next == Some(right_idx) - && right_slot.prev == Some(left_idx) - && left_slot.mountain.height == right_slot.mountain.height - } - - fn ordered_mountains(&self) -> Vec<&BeltMountain> { - let mut mountains = Vec::new(); - let mut next = self.head; - while let Some(idx) = next { - let slot = self.mountain_slot(idx); - mountains.push(&slot.mountain); - next = slot.next; - } - mountains - } - - fn mountain_slot(&self, idx: usize) -> &BeltMountainSlot { - self.mountains[idx].as_ref().expect("mountain slot must be active") - } - - fn mountain_slot_mut(&mut self, idx: usize) -> &mut BeltMountainSlot { - self.mountains[idx].as_mut().expect("mountain slot must be active") - } -} - -#[derive(Debug, Clone)] -struct BeltMountainSlot { - mountain: BeltMountain, - prev: Option, - next: Option, - mergeable_prev: Option, - mergeable_next: Option, - in_mergeable_list: bool, -} - -#[derive(Debug, Clone, Default)] -struct BeltHashArray { - nodes: Vec>, -} - -impl BeltHashArray { - fn get(&self, index: HashIndex) -> Option { - self.nodes.get(index.0).copied().flatten() - } - - fn set(&mut self, index: HashIndex, value: Word) { - if self.nodes.len() <= index.0 { - self.nodes.resize(index.0 + 1, None); - } - self.nodes[index.0] = Some(value); - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct BeltSummary { - num_leaves: usize, - root: Word, -} - -impl BeltSummary { - /// Builds a summary by double-bagging a mountain-order peak list. - /// - /// # Errors - /// Returns an error if the number of peaks does not match the shape implied by `num_leaves`. - pub fn from_peaks(num_leaves: usize, peaks: &[Word]) -> Result { - let expected = shape_mountains(num_leaves).len(); - if peaks.len() != expected { - return Err(MmrError::InvalidPeaks(format!( - "expected {expected} peaks for {num_leaves} leaves but got {}", - peaks.len() - ))); - } - - Ok(Self { - num_leaves, - root: bag_peaks(num_leaves, peaks), - }) - } - - /// Returns the number of leaves authenticated by this summary. - /// - /// The count lets consumers derive the expected shape and proof handedness. - pub fn num_leaves(&self) -> usize { - self.num_leaves - } - - pub fn root(&self) -> Word { - self.root - } -} - -/// Incremental update to a mountain-order summary. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MmrBeltDelta { - from_num_leaves: usize, - to_num_leaves: usize, - new_tail_peaks: Vec, - /// Authentication nodes for extending tracked leaves whose mountain merged. - merge_auth: BTreeMap<(usize, usize), Word>, -} - -impl MmrBeltDelta { - pub fn from_num_leaves(&self) -> usize { - self.from_num_leaves - } - - pub fn to_num_leaves(&self) -> usize { - self.to_num_leaves - } - - pub fn new_tail_peaks(&self) -> &[Word] { - &self.new_tail_peaks - } - - pub fn num_merge_auth_nodes(&self) -> usize { - self.merge_auth.len() - } - - /// Applies this delta to a client's old mountain-order peak list. - /// - /// # Errors - /// Returns an error if `old_peaks` does not match the shape implied by - /// [`Self::from_num_leaves`], or if the delta's tail does not complete the target peak - /// list. - pub fn apply(&self, old_peaks: &[Word]) -> Result, MmrError> { - let old_len = shape_mountains(self.from_num_leaves).len(); - if old_peaks.len() != old_len { - return Err(MmrError::InvalidPeaks(format!( - "expected {old_len} peaks for {} leaves but got {}", - self.from_num_leaves, - old_peaks.len() - ))); - } - - let common = common_peak_prefix_len(self.from_num_leaves, self.to_num_leaves); - let new_len = shape_mountains(self.to_num_leaves).len(); - if common + self.new_tail_peaks.len() != new_len { - return Err(MmrError::InvalidUpdate); - } - - let mut peaks = old_peaks[..common].to_vec(); - peaks.extend_from_slice(&self.new_tail_peaks); - - Ok(peaks) - } -} - -// PARTIAL MERKLE MOUNTAIN BELT -// ================================================================================================ - -/// Client-side view of a Merkle Mountain Belt. -/// -/// Stores the mountain-order summary plus within-mountain paths for tracked leaves. Range and belt -/// paths are rebuilt from the local peak list on demand. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PartialMmrBelt { - num_leaves: usize, - peaks: Vec, - tracked: BTreeMap, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct TrackedLeaf { - leaf: Word, - mountain_start: usize, - mountain_height: usize, - within_path: Vec, -} - -impl PartialMmrBelt { - /// Bootstraps a client view from a trusted mountain-order summary. - /// - /// # Errors - /// Returns an error if the number of peaks does not match the shape implied by `num_leaves`. - pub fn from_peaks(num_leaves: usize, peaks: Vec) -> Result { - let expected = shape_mountains(num_leaves).len(); - if peaks.len() != expected { - return Err(MmrError::InvalidPeaks(format!( - "expected {expected} peaks for {num_leaves} leaves but got {}", - peaks.len() - ))); - } - - Ok(Self { - num_leaves, - peaks, - tracked: BTreeMap::new(), - }) - } - - pub fn num_leaves(&self) -> usize { - self.num_leaves - } - - pub fn peaks(&self) -> &[Word] { - &self.peaks - } - - pub fn summary(&self) -> BeltSummary { - BeltSummary { - num_leaves: self.num_leaves, - root: bag_peaks(self.num_leaves, &self.peaks), - } - } - - pub fn is_tracked(&self, pos: usize) -> bool { - self.tracked.contains_key(&pos) - } - - pub fn num_tracked(&self) -> usize { - self.tracked.len() - } - - pub fn get(&self, pos: usize) -> Option { - self.tracked.get(&pos).map(|tracked| tracked.leaf) - } - - /// Starts tracking the leaf authenticated by `proof` against the current summary. - /// - /// # Errors - /// Returns an error if the proof does not authenticate against the current summary, or if its - /// position is out of range. - pub fn track(&mut self, proof: &BeltProof) -> Result<(), MmrError> { - if !proof.verify(&self.summary()) { - return Err(MmrError::PeakPathMismatch); - } - - let shape = shape_mountains(self.num_leaves); - let mountain_idx = shape_mountain_for_position(&shape, proof.position()) - .ok_or(MmrError::PositionNotFound(proof.position()))?; - let mountain = shape[mountain_idx]; - - self.tracked.insert( - proof.position(), - TrackedLeaf { - leaf: proof.leaf(), - mountain_start: mountain.start, - mountain_height: mountain.height, - within_path: proof.nodes[..mountain.height].to_vec(), - }, - ); - - Ok(()) - } - - pub fn untrack(&mut self, pos: usize) -> bool { - self.tracked.remove(&pos).is_some() - } - - /// Rebuilds a membership proof for a tracked leaf, deriving the bagging path from local peaks. - pub fn open(&self, pos: usize) -> Result, MmrError> { - let Some(tracked) = self.tracked.get(&pos) else { - return Ok(None); - }; - - let shape = shape_mountains(self.num_leaves); - let mountain_idx = - shape_mountain_for_position(&shape, pos).ok_or(MmrError::PositionNotFound(pos))?; - - let mut nodes = tracked.within_path.clone(); - nodes.extend(bagging_path_nodes(&shape, &self.peaks, &shape_ranges(&shape), mountain_idx)); - - Ok(Some(BeltProof { position: pos, leaf: tracked.leaf, nodes })) - } - - /// Applies an increment delta to the summary and tracked paths. - /// - /// # Errors - /// Returns an error if the delta does not originate from the current state, if it does not - /// apply cleanly to the current peaks, or if it lacks an authentication node required to - /// extend a tracked leaf. - pub fn apply(&mut self, delta: &MmrBeltDelta) -> Result<(), MmrError> { - if delta.from_num_leaves() != self.num_leaves { - return Err(MmrError::InvalidUpdate); - } - - self.peaks = delta.apply(&self.peaks)?; - self.num_leaves = delta.to_num_leaves(); - - let new_shape = shape_mountains(self.num_leaves); - for (&pos, tracked) in self.tracked.iter_mut() { - let mountain_idx = shape_mountain_for_position(&new_shape, pos) - .ok_or(MmrError::PositionNotFound(pos))?; - let new_mountain = new_shape[mountain_idx]; - - for (side, sibling_start, height) in - climb_to_peak(tracked.mountain_start, tracked.mountain_height, new_mountain.height) - { - let &value = delta - .merge_auth - .get(&(sibling_start, height)) - .ok_or(MmrError::InvalidUpdate)?; - tracked.within_path.push(BeltProofNode { value, side }); - } - - tracked.mountain_start = new_mountain.start; - tracked.mountain_height = new_mountain.height; - } - - Ok(()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BeltProof { - position: usize, - leaf: Word, - nodes: Vec, -} - -impl BeltProof { - pub fn position(&self) -> usize { - self.position - } - - pub fn leaf(&self) -> Word { - self.leaf - } - - pub fn verify(&self, summary: &BeltSummary) -> bool { - if self.position >= summary.num_leaves { - return false; - } - - let Some(steps) = proof_steps_for_position(summary.num_leaves, self.position) else { - return false; - }; - - if self.nodes.len() != steps.len() - || self.nodes.iter().zip(&steps).any(|(node, (side, _))| node.side != *side) - { - return false; - } - - let root = - self.nodes - .iter() - .zip(&steps) - .fold(self.leaf, |current, (node, (side, domain))| match side { - SiblingSide::Left => domain.merge(node.value, current), - SiblingSide::Right => domain.merge(current, node.value), - }); - - root == summary.root - } - - #[cfg(test)] - fn set_leaf_for_testing(&mut self, leaf: Word) { - self.leaf = leaf; - } - - #[cfg(test)] - fn set_position_for_testing(&mut self, position: usize) { - self.position = position; - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct BeltProofNode { - value: Word, - side: SiblingSide, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SiblingSide { - Left, - Right, -} - -#[derive(Debug, Clone)] -struct BeltMountain { - start: usize, - height: usize, - index: HashIndex, -} - -impl BeltMountain { - fn new(start: usize, index: HashIndex) -> Self { - Self { start, height: 0, index } - } - - fn merge(self, other: Self) -> Self { - debug_assert_eq!(self.height, other.height); - debug_assert_eq!(self.start + self.size(), other.start); - - let index = parent_hash_index(self.index); - debug_assert_eq!(index, parent_hash_index(other.index)); - Self { - start: self.start, - height: self.height + 1, - index, - } - } - - fn size(&self) -> usize { - 1usize << self.height - } -} - -fn bagging_path_nodes( - shape: &[ShapeMountain], - peaks: &[Word], - ranges: &[Range], - mountain_idx: usize, -) -> Vec { - let range_idx = ranges - .iter() - .position(|range| range.contains(&mountain_idx)) - .expect("mountain must be part of a range"); - let range = ranges[range_idx].clone(); - - let mut nodes = Vec::new(); - - let prefix = bag_range(&shape[range.start..mountain_idx], &peaks[range.start..mountain_idx]); - nodes.push(BeltProofNode { value: prefix, side: SiblingSide::Left }); - for &peak in &peaks[mountain_idx + 1..range.end] { - nodes.push(BeltProofNode { value: peak, side: SiblingSide::Right }); - } - - let range_roots = ranges - .iter() - .map(|range| bag_range(&shape[range.clone()], &peaks[range.clone()])) - .collect::>(); - let prefix_belt = bag_belt(&range_roots[..range_idx]); - nodes.push(BeltProofNode { - value: prefix_belt, - side: SiblingSide::Left, - }); - for &root in &range_roots[range_idx + 1..] { - nodes.push(BeltProofNode { value: root, side: SiblingSide::Right }); - } - - nodes -} - -fn proof_steps_for_position( - num_leaves: usize, - position: usize, -) -> Option> { - if position >= num_leaves { - return None; - } - - let shape = shape_mountains(num_leaves); - let mountain_idx = shape_mountain_for_position(&shape, position)?; - let mountain = shape[mountain_idx]; - - let mut steps: Vec<(SiblingSide, FoldDomain)> = - balanced_tree_sides(mountain.height, position - mountain.start) - .into_iter() - .map(|side| (side, FoldDomain::Mountain)) - .collect(); - - let ranges = shape_ranges(&shape); - let range_idx = ranges.iter().position(|range| range.contains(&mountain_idx))?; - let range = ranges[range_idx].clone(); - - steps.push((SiblingSide::Left, FoldDomain::Range)); - for _ in &shape[mountain_idx + 1..range.end] { - steps.push((SiblingSide::Right, FoldDomain::Range)); - } - - steps.push((SiblingSide::Left, FoldDomain::Belt)); - for _ in (range_idx + 1)..ranges.len() { - steps.push((SiblingSide::Right, FoldDomain::Belt)); - } - - Some(steps) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct HashIndex(usize); - -fn leaf_hash_index(position: usize) -> HashIndex { - HashIndex(2 * (position + 1) + 1) -} - -fn parent_hash_index(child: HashIndex) -> HashIndex { - let span = 1usize << (child.0.trailing_zeros() as usize + 2); - HashIndex(child.0 + child.0 % span) -} - -#[cfg(test)] -fn hash_children(parent: HashIndex) -> (HashIndex, HashIndex) { - debug_assert!(parent.0 % 2 == 0); - let span = 1usize << (parent.0.trailing_zeros() as usize - 1); - (HashIndex(parent.0 - 3 * span), HashIndex(parent.0 - span)) -} - -fn node_hash_index(start: usize, height: usize) -> HashIndex { - // Lemma 24 closed form for the node covering `[start, start + 2^height)`. - HashIndex((2 * (start >> height) + 3) << height) -} - -#[derive(Debug, Clone, Copy)] -struct ShapeMountain { - start: usize, - height: usize, -} - -impl ShapeMountain { - fn size(&self) -> usize { - 1usize << self.height - } -} - -fn shape_from_mountains(mountains: &[&BeltMountain]) -> Vec { - mountains - .iter() - .map(|mountain| ShapeMountain { - start: mountain.start, - height: mountain.height, - }) - .collect() -} - -fn shape_mountains(num_leaves: usize) -> Vec { - if num_leaves == 0 { - return Vec::new(); - } - - let bits = num_leaves + 1; - let num_mountains = bits.ilog2() as usize; - - let mut mountains = Vec::with_capacity(num_mountains); - let mut start = 0; - for position in (0..num_mountains).rev() { - let bit = (bits >> position) & 1; - let height = position + bit; - mountains.push(ShapeMountain { start, height }); - start += 1usize << height; - } - debug_assert_eq!(start, num_leaves); - - mountains -} - -fn shape_mountain_for_position(mountains: &[ShapeMountain], position: usize) -> Option { - mountains - .iter() - .position(|mountain| position < mountain.start + mountain.size()) -} - -fn shape_ranges(mountains: &[ShapeMountain]) -> Vec> { - if mountains.is_empty() { - return Vec::new(); - } - - let mut ranges = Vec::new(); - let mut start = 0; - for idx in 0..mountains.len() - 1 { - if shape_range_split_after(mountains, idx) { - ranges.push(start..idx + 1); - start = idx + 1; - } - } - ranges.push(start..mountains.len()); - ranges -} - -fn shape_range_split_after(mountains: &[ShapeMountain], idx: usize) -> bool { - let left = mountains[idx].height; - let right = mountains[idx + 1].height; - let drops_by_two = left == right + 2; - let left_is_right_member_of_mergeable_pair = idx > 0 && mountains[idx - 1].height == left; - - drops_by_two || left_is_right_member_of_mergeable_pair -} - -fn sibling_and_parent_start(start: usize, height: usize) -> (SiblingSide, usize, usize) { - let span = 1usize << height; - if (start >> height) & 1 == 0 { - (SiblingSide::Right, start + span, start) - } else { - (SiblingSide::Left, start - span, start - span) - } -} - -fn climb_to_peak( - start: usize, - from_height: usize, - to_height: usize, -) -> impl Iterator { - let mut start = start; - (from_height..to_height).map(move |height| { - let (side, sibling_start, parent_start) = sibling_and_parent_start(start, height); - start = parent_start; - (side, sibling_start, height) - }) -} - -fn bag_peaks(num_leaves: usize, peaks: &[Word]) -> Word { - let shape = shape_mountains(num_leaves); - debug_assert_eq!(shape.len(), peaks.len()); - - let range_roots = shape_ranges(&shape) - .into_iter() - .map(|range| bag_range(&shape[range.clone()], &peaks[range])) - .collect::>(); - - bag_belt(&range_roots) -} - -fn bag_range(mountains: &[ShapeMountain], peaks: &[Word]) -> Word { - debug_assert_eq!(mountains.len(), peaks.len()); - mountains - .iter() - .zip(peaks) - .fold(EMPTY_WORD, |acc, (_, &peak)| FoldDomain::Range.merge(acc, peak)) -} - -fn bag_belt(range_roots: &[Word]) -> Word { - range_roots - .iter() - .fold(EMPTY_WORD, |acc, &root| FoldDomain::Belt.merge(acc, root)) -} - -fn common_peak_prefix_len(from_num_leaves: usize, to_num_leaves: usize) -> usize { - let from_shape = shape_mountains(from_num_leaves); - let to_shape = shape_mountains(to_num_leaves); - - from_shape - .iter() - .zip(to_shape.iter()) - .take_while(|(from, to)| from.start == to.start && from.height == to.height) - .count() -} - -fn sibling_side(is_left_child: bool) -> SiblingSide { - if is_left_child { - SiblingSide::Right - } else { - SiblingSide::Left - } -} - -fn balanced_tree_sides(height: usize, mut local_position: usize) -> Vec { - let mut sides = Vec::with_capacity(height); - for _ in 0..height { - sides.push(sibling_side(local_position & 1 == 0)); - local_position >>= 1; - } - sides -} - -#[cfg(test)] -mod tests { - use alloc::{vec, vec::Vec}; - - use super::{ - BeltSummary, HashIndex, MmrBelt, PartialMmrBelt, bag_range, hash_children, leaf_hash_index, - node_hash_index, parent_hash_index, shape_mountains, shape_ranges, - }; - use crate::{EMPTY_WORD, Word, hash::poseidon2::Poseidon2, merkle::int_to_node}; - - fn shape_heights(num_leaves: usize) -> Vec { - shape_mountains(num_leaves).iter().map(|mountain| mountain.height).collect() - } - - fn shape_hash_indices(num_leaves: usize) -> Vec { - shape_mountains(num_leaves) - .iter() - .map(|mountain| node_hash_index(mountain.start, mountain.height).0) - .collect() - } - - fn hash_range(leaves: &[Word], start: usize, height: usize) -> Word { - if height == 0 { - return leaves[start]; - } - - let half = 1usize << (height - 1); - Poseidon2::merge(&[ - hash_range(leaves, start, height - 1), - hash_range(leaves, start + half, height - 1), - ]) - } - - #[test] - fn belt_hash_indices_match_clojure_layout() { - let leaf_indices = (0..6).map(|position| leaf_hash_index(position).0).collect::>(); - assert_eq!(leaf_indices, vec![3, 5, 7, 9, 11, 13]); - - assert_eq!(parent_hash_index(HashIndex(3)), HashIndex(6)); - assert_eq!(parent_hash_index(HashIndex(5)), HashIndex(6)); - assert_eq!(hash_children(HashIndex(6)), (HashIndex(3), HashIndex(5))); - - assert_eq!(parent_hash_index(HashIndex(6)), HashIndex(12)); - assert_eq!(parent_hash_index(HashIndex(10)), HashIndex(12)); - assert_eq!(hash_children(HashIndex(12)), (HashIndex(6), HashIndex(10))); - } - - #[test] - fn belt_shape_hash_indices_match_peak_layout() { - assert_eq!(shape_hash_indices(1), vec![3]); - assert_eq!(shape_hash_indices(2), vec![6]); - assert_eq!(shape_hash_indices(3), vec![6, 7]); - assert_eq!(shape_hash_indices(4), vec![6, 10]); - assert_eq!(shape_hash_indices(5), vec![12, 11]); - assert_eq!(shape_hash_indices(9), vec![12, 20, 19]); - assert_eq!(shape_hash_indices(10), vec![12, 20, 22]); - } - - #[test] - fn belt_hash_array_stores_live_mountain_nodes() { - let mut belt = MmrBelt::new(); - let leaves = (0..64).map(int_to_node).collect::>(); - - for leaf in leaves.iter().copied() { - belt.add(leaf).unwrap(); - } - - for mountain in belt.ordered_mountains() { - for height in 0..=mountain.height { - let width = 1usize << height; - for start in (mountain.start..mountain.start + mountain.size()).step_by(width) { - assert_eq!( - belt.hashes.get(node_hash_index(start, height)), - Some(hash_range(&leaves, start, height)), - "missing hash for node [{start}, {})", - start + width - ); - } - } - } - } - - #[test] - fn belt_lazy_append_height_sequence() { - let mut belt = MmrBelt::new(); - let expected = [ - vec![0], - vec![1], - vec![1, 0], - vec![1, 1], - vec![2, 0], - vec![2, 1], - vec![2, 1, 0], - vec![2, 1, 1], - vec![2, 2, 0], - vec![2, 2, 1], - vec![3, 1, 0], - vec![3, 1, 1], - vec![3, 2, 0], - ]; - - for (idx, expected_heights) in expected.into_iter().enumerate() { - belt.add(int_to_node(idx as u64)).unwrap(); - assert_eq!(belt.mountain_heights(), expected_heights); - } - } - - #[test] - fn belt_append_performs_at_most_one_mountain_merge() { - let mut belt = MmrBelt::new(); - - for idx in 0..128 { - let merge_count = belt.add(int_to_node(idx)).unwrap(); - assert!(merge_count <= 1); - } - } - - #[test] - fn belt_append_touches_constant_local_storage() { - let mut belt = MmrBelt::new(); - - for idx in 0..128 { - let before = belt.storage_slots_for_testing(); - belt.add(int_to_node(idx)).unwrap(); - let after = belt.storage_slots_for_testing(); - - assert!(after - before <= 2); - } - } - - #[test] - fn belt_tracks_rightmost_mergeable_pair_without_stale_stack() { - let mut belt = MmrBelt::new(); - - for idx in 0..512 { - belt.add(int_to_node(idx)).unwrap(); - - let mountains = belt.ordered_mountains(); - let expected = mountains - .windows(2) - .rev() - .find(|pair| pair[0].height == pair[1].height) - .map(|pair| (pair[0].start, pair[1].start)); - - assert_eq!(belt.rightmost_mergeable_pair_for_testing(), expected, "after {idx}"); - } - } - - #[test] - fn belt_range_splits_follow_mmb_rules() { - let mut belt = MmrBelt::new(); - let expected = [ - vec![vec![0]], - vec![vec![1]], - vec![vec![1, 0]], - vec![vec![1, 1]], - vec![vec![2], vec![0]], - vec![vec![2, 1]], - vec![vec![2, 1, 0]], - vec![vec![2, 1, 1]], - vec![vec![2, 2], vec![0]], - vec![vec![2, 2], vec![1]], - vec![vec![3], vec![1, 0]], - vec![vec![3], vec![1, 1]], - vec![vec![3, 2], vec![0]], - vec![vec![3, 2, 1]], - vec![vec![3, 2, 1, 0]], - vec![vec![3, 2, 1, 1]], - ]; - - for (idx, expected_ranges) in expected.into_iter().enumerate() { - belt.add(int_to_node(idx as u64)).unwrap(); - assert_eq!(belt.range_heights(), expected_ranges); - } - } - - #[test] - fn belt_summary_root_is_stable_for_same_leaves() { - let mut first = MmrBelt::new(); - let mut second = MmrBelt::new(); - - for idx in 0..32 { - let leaf = int_to_node(idx); - first.add(leaf).unwrap(); - second.add(leaf).unwrap(); - } - - assert_eq!(first.summary().root(), second.summary().root()); - assert_eq!(first.summary().num_leaves(), 32); - } - - #[test] - fn belt_openings_verify_for_all_leaves() { - let mut belt = MmrBelt::new(); - let leaves = (0..37).map(int_to_node).collect::>(); - for leaf in leaves.iter().copied() { - belt.add(leaf).unwrap(); - } - let summary = belt.summary(); - - for (position, leaf) in leaves.into_iter().enumerate() { - let proof = belt.open(position).unwrap(); - assert_eq!(proof.position(), position); - assert_eq!(proof.leaf(), leaf); - assert!(proof.verify(&summary)); - } - } - - #[test] - fn belt_opening_rejects_wrong_leaf() { - let mut belt = MmrBelt::new(); - for idx in 0..16 { - belt.add(int_to_node(idx)).unwrap(); - } - let summary = belt.summary(); - let mut proof = belt.open(7).unwrap(); - - proof.set_leaf_for_testing(int_to_node(999)); - - assert!(!proof.verify(&summary)); - } - - #[test] - fn belt_opening_rejects_wrong_position() { - let mut belt = MmrBelt::new(); - for idx in 0..37 { - belt.add(int_to_node(idx)).unwrap(); - } - let summary = belt.summary(); - let mut proof = belt.open(7).unwrap(); - - proof.set_position_for_testing(5); - - assert!(!proof.verify(&summary)); - } - - #[test] - fn belt_root_binds_shape_without_belt_domain_separation() { - let mut belt = MmrBelt::new(); - for idx in 0..2 { - belt.add(int_to_node(idx)).unwrap(); - } - let summary = belt.summary(); - let peaks = belt.peaks(); - assert_eq!(peaks.len(), 1); - assert_ne!(summary.root(), peaks[0], "root must not be transparent to its peak"); - - let mut bigger = MmrBelt::new(); - for idx in 0..3 { - bigger.add(int_to_node(idx)).unwrap(); - } - assert_ne!(bigger.summary().root(), summary.root()); - } - - #[test] - fn belt_second_bagging_uses_plain_merkle_merge() { - let mut belt = MmrBelt::new(); - for idx in 0..5 { - belt.add(int_to_node(idx)).unwrap(); - } - - let shape = shape_mountains(belt.num_leaves()); - let peaks = belt.peaks(); - let range_roots = shape_ranges(&shape) - .into_iter() - .map(|range| bag_range(&shape[range.clone()], &peaks[range])) - .collect::>(); - assert!(range_roots.len() > 1); - - let expected = - range_roots.iter().fold(EMPTY_WORD, |acc, &root| Poseidon2::merge(&[acc, root])); - - assert_eq!(belt.summary().root(), expected); - } - - #[test] - fn belt_range_bagging_uses_plain_merkle_merge() { - let mut belt = MmrBelt::new(); - for idx in 0..9 { - belt.add(int_to_node(idx)).unwrap(); - } - - let shape = shape_mountains(belt.num_leaves()); - let peaks = belt.peaks(); - let first_range = shape_ranges(&shape).into_iter().next().unwrap(); - assert!(first_range.len() > 1); - - let expected = peaks[first_range.clone()] - .iter() - .fold(EMPTY_WORD, |acc, &peak| Poseidon2::merge(&[acc, peak])); - - assert_eq!(bag_range(&shape[first_range.clone()], &peaks[first_range]), expected); - } - - #[test] - fn belt_shape_derivation_matches_live_structure_across_pow2() { - let mut belt = MmrBelt::new(); - let leaves = (0..4100u64).map(int_to_node).collect::>(); - for leaf in leaves.iter().copied() { - belt.add(leaf).unwrap(); - } - let summary = belt.summary(); - - for (position, leaf) in leaves.into_iter().enumerate() { - let proof = belt.open(position).unwrap(); - assert_eq!(proof.leaf(), leaf, "leaf mismatch at {position}"); - assert!(proof.verify(&summary), "verify failed at position {position}"); - } - } - - #[test] - fn belt_summary_from_peaks_matches_full_summary() { - let mut belt = MmrBelt::new(); - for idx in 0..100 { - belt.add(int_to_node(idx)).unwrap(); - let summary = belt.summary(); - let from_peaks = BeltSummary::from_peaks(summary.num_leaves(), &belt.peaks()).unwrap(); - assert_eq!(from_peaks, summary); - } - } - - #[test] - fn belt_delta_resyncs_client_summary() { - let total = 600usize; - for from in [0usize, 1, 7, 64, 255, 256, 511] { - let mut belt = MmrBelt::new(); - for idx in 0..from { - belt.add(int_to_node(idx as u64)).unwrap(); - } - let client_peaks = belt.peaks(); - - for idx in from..total { - belt.add(int_to_node(idx as u64)).unwrap(); - } - - let delta = belt.delta(from).unwrap(); - assert_eq!(delta.from_num_leaves(), from); - assert_eq!(delta.to_num_leaves(), total); - - let updated = delta.apply(&client_peaks).unwrap(); - assert_eq!(updated, belt.peaks(), "resynced peaks must match (from {from})"); - assert_eq!( - BeltSummary::from_peaks(total, &updated).unwrap(), - belt.summary(), - "resynced commitment must match (from {from})" - ); - } - } - - #[test] - fn belt_delta_is_logarithmic_in_increment() { - let mut belt = MmrBelt::new(); - for idx in 0..100_000u64 { - belt.add(int_to_node(idx)).unwrap(); - } - - for k in [1usize, 2, 10, 100, 1000] { - let delta = belt.delta(100_000 - k).unwrap(); - let bound = 2 * (usize::BITS - k.leading_zeros()) as usize + 4; - assert!( - delta.new_tail_peaks().len() <= bound, - "k={k}: tail {} exceeded bound {bound}", - delta.new_tail_peaks().len() - ); - } - } - - #[test] - fn belt_delta_rejects_future_origin() { - let mut belt = MmrBelt::new(); - for idx in 0..10 { - belt.add(int_to_node(idx)).unwrap(); - } - assert!(belt.delta(11).is_err()); - } - - #[test] - fn partial_belt_tracks_and_opens_like_full_belt() { - let mut belt = MmrBelt::new(); - let leaves = (0..50u64).map(int_to_node).collect::>(); - for leaf in leaves.iter().copied() { - belt.add(leaf).unwrap(); - } - - let mut partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); - assert_eq!(partial.summary(), belt.summary()); - - for position in 0..leaves.len() { - partial.track(&belt.open(position).unwrap()).unwrap(); - } - assert_eq!(partial.num_tracked(), leaves.len()); - - for (position, leaf) in leaves.iter().copied().enumerate() { - let proof = partial.open(position).unwrap().unwrap(); - assert_eq!(proof, belt.open(position).unwrap()); - assert_eq!(partial.get(position), Some(leaf)); - assert!(proof.verify(&partial.summary())); - } - } - - #[test] - fn partial_belt_from_peaks_rejects_wrong_count() { - let mut belt = MmrBelt::new(); - for idx in 0..7 { - belt.add(int_to_node(idx)).unwrap(); - } - let mut peaks = belt.peaks(); - peaks.pop(); - assert!(PartialMmrBelt::from_peaks(belt.num_leaves(), peaks).is_err()); - } - - #[test] - fn partial_belt_track_rejects_unauthenticated_proof() { - let mut belt = MmrBelt::new(); - for idx in 0..16 { - belt.add(int_to_node(idx)).unwrap(); - } - let mut partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); - - let mut proof = belt.open(5).unwrap(); - proof.set_leaf_for_testing(int_to_node(999)); - assert!(partial.track(&proof).is_err()); - assert!(!partial.is_tracked(5)); - } - - #[test] - fn partial_belt_apply_extends_all_tracks_in_place() { - let from = 200usize; - let to = 260usize; - - let mut belt = MmrBelt::new(); - for idx in 0..from { - belt.add(int_to_node(idx as u64)).unwrap(); - } - - let mut partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); - for position in 0..from { - partial.track(&belt.open(position).unwrap()).unwrap(); - } - - for idx in from..to { - belt.add(int_to_node(idx as u64)).unwrap(); - } - - partial.apply(&belt.delta(from).unwrap()).unwrap(); - - assert_eq!(partial.num_leaves(), to); - assert_eq!(partial.summary(), belt.summary()); - assert_eq!(partial.num_tracked(), from); - - for position in 0..from { - assert!(partial.is_tracked(position), "leaf {position} must still be tracked"); - assert_eq!(partial.open(position).unwrap().unwrap(), belt.open(position).unwrap()); - } - } - - #[test] - fn partial_belt_apply_extends_across_many_increments() { - let mut belt = MmrBelt::new(); - for idx in 0..40u64 { - belt.add(int_to_node(idx)).unwrap(); - } - - let mut partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); - let tracked = [0usize, 1, 17, 38, 39]; - for &position in &tracked { - partial.track(&belt.open(position).unwrap()).unwrap(); - } - - let mut next = 40u64; - for _ in 0..30 { - let from = belt.num_leaves(); - for _ in 0..7 { - belt.add(int_to_node(next)).unwrap(); - next += 1; - } - partial.apply(&belt.delta(from).unwrap()).unwrap(); - - assert_eq!(partial.summary(), belt.summary()); - for &position in &tracked { - assert_eq!(partial.open(position).unwrap().unwrap(), belt.open(position).unwrap()); - } - } - } - - #[test] - fn partial_belt_protocol_model_resyncs_after_offline_increment() { - let from = 128usize; - let to = 191usize; - let leaves = (0..to as u64).map(int_to_node).collect::>(); - - let mut full_node = MmrBelt::new(); - for &leaf in &leaves[..from] { - full_node.add(leaf).unwrap(); - } - - let mut client = - PartialMmrBelt::from_peaks(full_node.num_leaves(), full_node.peaks()).unwrap(); - for &position in &[0usize, 1, 7, 63, 64, 100, 127] { - client.track(&full_node.open(position).unwrap()).unwrap(); - } - assert_eq!(client.summary(), full_node.summary()); - - for &leaf in &leaves[from..to] { - full_node.add(leaf).unwrap(); - } - let server_delta = full_node.delta(from).unwrap(); - - assert_ne!(client.summary(), full_node.summary()); - client.apply(&server_delta).unwrap(); - assert_eq!(client.summary(), full_node.summary()); - - for &position in &[0usize, 1, 7, 63, 64, 100, 127] { - let client_proof = client.open(position).unwrap().unwrap(); - assert!(client_proof.verify(&client.summary())); - assert_eq!(client_proof, full_node.open(position).unwrap()); - assert_eq!(client.get(position), Some(leaves[position])); - } - - let newest_position = to - 1; - client.track(&full_node.open(newest_position).unwrap()).unwrap(); - assert_eq!(client.get(newest_position), Some(leaves[newest_position])); - assert!(client.open(newest_position).unwrap().unwrap().verify(&client.summary())); - } - - #[test] - fn partial_belt_delta_merge_auth_is_polylogarithmic() { - let mut belt = MmrBelt::new(); - for idx in 0..100_000u64 { - belt.add(int_to_node(idx)).unwrap(); - } - - for k in [1usize, 2, 16, 256, 4096] { - let delta = belt.delta(100_000 - k).unwrap(); - let log_k = (usize::BITS - k.leading_zeros()) as usize; - let bound = 4 * log_k * log_k + 8; - assert!( - delta.num_merge_auth_nodes() <= bound, - "k={k}: {} auth nodes exceeded bound {bound}", - delta.num_merge_auth_nodes() - ); - } - } - - #[test] - fn partial_belt_open_untracked_returns_none() { - let mut belt = MmrBelt::new(); - for idx in 0..16 { - belt.add(int_to_node(idx)).unwrap(); - } - let partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); - assert!(partial.open(3).unwrap().is_none()); - } - - #[test] - fn belt_height_sequences_match_paper() { - // Golden S_n sequences from arXiv:2511.13582, §3.1 and Figures 5/7/9. - let golden: [(usize, &[usize]); 4] = [ - (9, &[2, 2, 0]), - (10, &[2, 2, 1]), - (11, &[3, 1, 0]), - (1337, &[9, 9, 7, 6, 6, 5, 4, 2, 2, 0]), - ]; - - for (num_leaves, expected) in golden { - assert_eq!(shape_heights(num_leaves), expected, "shape S_{num_leaves}"); - - let mut belt = MmrBelt::new(); - for idx in 0..num_leaves { - belt.add(int_to_node(idx as u64)).unwrap(); - } - assert_eq!(belt.mountain_heights(), expected, "live S_{num_leaves}"); - } - } - - #[test] - fn belt_merge_peak_lands_in_last_two_ranges() { - // Lemma 16. - for num_leaves in 2..4096usize { - if (num_leaves + 1).is_power_of_two() { - continue; // merge step skipped on this append - } - - let shape = shape_mountains(num_leaves); - let merge_idx = shape.len() - 1 - (num_leaves + 1).trailing_zeros() as usize; - - let ranges = shape_ranges(&shape); - let range_idx = ranges - .iter() - .position(|range| range.contains(&merge_idx)) - .expect("merge peak must lie in a range"); - - assert_eq!( - ranges[range_idx].end, - merge_idx + 1, - "n={num_leaves}: merge peak must sit at the right end of its range" - ); - assert!( - range_idx + 2 >= ranges.len(), - "n={num_leaves}: merge peak must be in the rightmost or second-rightmost range" - ); - } - } -} diff --git a/miden-crypto/src/merkle/mmr/belt/bagging.rs b/miden-crypto/src/merkle/mmr/belt/bagging.rs new file mode 100644 index 0000000000..ad8a2b2be4 --- /dev/null +++ b/miden-crypto/src/merkle/mmr/belt/bagging.rs @@ -0,0 +1,612 @@ +use alloc::{format, vec::Vec}; +use core::ops::Range; + +use super::shape::*; +use super::{BeltMountain, MmrError}; +use crate::{EMPTY_WORD, Word, hash::poseidon2::Poseidon2}; + +struct AppendChangedRanges { + ranges: [Range; 2], + len: usize, +} + +impl AppendChangedRanges { + fn new() -> Self { + Self { ranges: [0..0, 0..0], len: 0 } + } + + fn push(&mut self, range: Range) -> Result<(), MmrError> { + if self.len == self.ranges.len() { + return Err(MmrError::InvalidUpdate); + } + + self.ranges[self.len] = range; + self.len += 1; + + Ok(()) + } + + fn iter(&self) -> impl Iterator> + '_ { + self.ranges[..self.len].iter().cloned() + } +} + +#[derive(Debug, Clone)] +pub(super) struct BeltBaggingState { + num_leaves: usize, + range_len: usize, + pub(super) ranges: Vec>, + range_roots: Vec, + range_nodes: Vec>, + belt_nodes: Vec, +} + +impl BeltBaggingState { + pub(super) fn from_roots(num_leaves: usize, roots: &[Word]) -> Result { + Self::from_roots_with_stats(num_leaves, roots).map(|(state, _)| state) + } + + pub(super) fn from_roots_with_stats( + num_leaves: usize, + roots: &[Word], + ) -> Result<(Self, usize), MmrError> { + let shape = shape_mountains(num_leaves); + if roots.len() != shape.len() { + return Err(MmrError::InvalidPeaks(format!( + "expected {} peaks for {num_leaves} leaves but got {}", + shape.len(), + roots.len() + ))); + } + + let mut hashes = 0; + let ranges = shape_ranges(&shape); + let mut range_roots = Vec::new(); + let mut range_nodes = Vec::new(); + for range in ranges.iter().cloned() { + let mut nodes = Vec::with_capacity(range.len()); + let mut prefix = EMPTY_WORD; + for &root in &roots[range] { + let node = RangeNode::new(prefix, root); + hashes += 1; + prefix = node.root; + nodes.push(node); + } + range_roots.push(prefix); + range_nodes.push(nodes); + } + + let range_len = range_roots.len(); + let mut belt_nodes = Vec::with_capacity(range_len); + let mut prefix = EMPTY_WORD; + for &range_root in &range_roots { + let node = BeltNode::new(prefix, range_root); + hashes += 1; + prefix = node.root; + belt_nodes.push(node); + } + + Ok(( + Self { + num_leaves, + range_len, + ranges, + range_roots, + range_nodes, + belt_nodes, + }, + hashes, + )) + } + + pub(super) fn append_update( + &mut self, + old_num_leaves: usize, + new_num_leaves: usize, + changed: &[ChangedMountain], + ) -> usize { + debug_assert_eq!(old_num_leaves + 1, new_num_leaves); + debug_assert!(!changed.is_empty()); + + if old_num_leaves == 0 { + debug_assert_eq!(changed.len(), 1); + debug_assert_eq!(changed[0].mountain, (ShapeMountain { start: 0, height: 0 })); + + let range_node = RangeNode::new(EMPTY_WORD, changed[0].root); + let belt_node = BeltNode::new(EMPTY_WORD, range_node.root); + *self = Self { + num_leaves: new_num_leaves, + range_len: 1, + ranges: Vec::from([0..1]), + range_roots: Vec::from([range_node.root]), + range_nodes: Vec::from([Vec::from([range_node])]), + belt_nodes: Vec::from([belt_node]), + }; + return 2; + } + + debug_assert_eq!(self.num_leaves, old_num_leaves); + debug_assert!(self.range_len <= self.ranges.len()); + debug_assert!(self.range_len <= self.range_roots.len()); + debug_assert!(self.range_len <= self.range_nodes.len()); + debug_assert!(self.range_len <= self.belt_nodes.len()); + + let recompute_idx = append_shape_recompute_index_trusted(old_num_leaves, changed); + let common = shape_range_index_for_num_leaves(old_num_leaves, recompute_idx) + .expect("append recompute index must have an old range"); + let suffix_start = self.ranges[common].start; + + self.num_leaves = new_num_leaves; + + let old_ranges_len = self.range_len; + + let mut hashes = 0; + let mut range_idx = common; + let changed_ranges = append_changed_ranges_trusted(new_num_leaves, suffix_start, changed); + for range in changed_ranges.iter() { + let changed_root = changed_root_for_range_trusted(new_num_leaves, &range, changed); + let range_hashes = self.update_changed_range_trusted( + old_num_leaves, + new_num_leaves, + range_idx, + range.clone(), + changed_root, + range_idx, + old_ranges_len, + ); + hashes += range_hashes; + range_idx += 1; + } + + let new_range_len = range_idx; + for range_idx in common..new_range_len { + let range_root = self.range_roots[range_idx]; + let left = if range_idx == 0 { + EMPTY_WORD + } else { + self.belt_nodes[range_idx - 1].root + }; + let node = BeltNode::new(left, range_root); + hashes += 1; + if range_idx == self.belt_nodes.len() { + self.belt_nodes.push(node); + } else { + self.belt_nodes[range_idx] = node; + } + } + self.range_len = new_range_len; + + hashes + } + + pub(super) fn update_changed_range_trusted( + &mut self, + old_num_leaves: usize, + new_num_leaves: usize, + range_idx: usize, + new_range: Range, + changed_root: Word, + first_old_range_idx: usize, + old_ranges_len: usize, + ) -> usize { + debug_assert!(new_range.start < new_range.end); + debug_assert!(range_idx <= self.ranges.len()); + debug_assert!(range_idx <= self.range_roots.len()); + debug_assert!(range_idx <= self.range_nodes.len()); + + let changed_idx = new_range.end - 1; + let changed = changed_idx - new_range.start; + let source_range_idx = if changed == 0 { + None + } else { + let old_idx = shape_mountain_index_for_num_leaves( + old_num_leaves, + shape_mountain_at_index(new_num_leaves, new_range.start) + .expect("new range prefix must start with a mountain"), + ) + .expect("new range prefix must reuse an old mountain"); + let old_range_idx = shape_range_index_for_num_leaves(old_num_leaves, old_idx) + .expect("reused mountain must belong to an old range"); + debug_assert!((first_old_range_idx..old_ranges_len).contains(&old_range_idx)); + let old_range = self.ranges[old_range_idx].clone(); + debug_assert_eq!(old_idx, old_range.start); + debug_assert!(old_range.start + changed <= old_range.end); + debug_assert!(self.range_nodes[old_range_idx].len() >= changed); + Some(old_range_idx) + }; + + if range_idx == self.range_nodes.len() { + self.range_nodes.push(Vec::new()); + } + if let Some(source_range_idx) = source_range_idx + && source_range_idx != range_idx + { + self.range_nodes.swap(source_range_idx, range_idx); + } + + let nodes = &mut self.range_nodes[range_idx]; + if changed == 0 { + nodes.clear(); + } else { + nodes.truncate(changed); + } + + let left = nodes.last().map_or(EMPTY_WORD, |node| node.root); + let node = RangeNode::new(left, changed_root); + let root = node.root; + nodes.push(node); + + if range_idx == self.ranges.len() { + self.ranges.push(new_range); + } else { + self.ranges[range_idx] = new_range; + } + if range_idx == self.range_roots.len() { + self.range_roots.push(root); + } else { + self.range_roots[range_idx] = root; + } + + 1 + } + + #[cfg(test)] + pub(super) fn changed_range_nodes( + &mut self, + old_num_leaves: usize, + new_num_leaves: usize, + new_range: Range, + changed_roots: &[ChangedMountain], + first_old_range_idx: usize, + old_ranges_len: usize, + ) -> Result<(Vec, Word, usize), MmrError> { + if new_range.start >= new_range.end { + return Err(MmrError::InvalidUpdate); + } + + let changed_idx = new_range.end - 1; + let changed_mountain = + shape_mountain_at_index(new_num_leaves, changed_idx).ok_or(MmrError::InvalidUpdate)?; + let changed_root = changed_roots + .iter() + .find(|changed| changed.mountain == changed_mountain) + .map(|changed| changed.root) + .ok_or(MmrError::InvalidUpdate)?; + + for changed in changed_roots { + if changed.mountain == changed_mountain { + continue; + } + if let Some(idx) = shape_mountain_index_for_num_leaves(new_num_leaves, changed.mountain) + && new_range.contains(&idx) + { + return Err(MmrError::InvalidUpdate); + } + } + + Ok(self.changed_range_nodes_trusted( + old_num_leaves, + new_num_leaves, + new_range, + changed_root, + first_old_range_idx, + old_ranges_len, + )) + } + + #[cfg(test)] + pub(super) fn changed_range_nodes_trusted( + &mut self, + old_num_leaves: usize, + new_num_leaves: usize, + new_range: Range, + changed_root: Word, + first_old_range_idx: usize, + old_ranges_len: usize, + ) -> (Vec, Word, usize) { + debug_assert!(new_range.start < new_range.end); + + let changed_idx = new_range.end - 1; + let changed = changed_idx - new_range.start; + let mut nodes = if changed == 0 { + Vec::new() + } else { + let old_idx = shape_mountain_index_for_num_leaves( + old_num_leaves, + shape_mountain_at_index(new_num_leaves, new_range.start) + .expect("new range prefix must start with a mountain"), + ) + .expect("new range prefix must reuse an old mountain"); + let old_range_idx = shape_range_index_for_num_leaves(old_num_leaves, old_idx) + .expect("reused mountain must belong to an old range"); + debug_assert!((first_old_range_idx..old_ranges_len).contains(&old_range_idx)); + let old_range = self.ranges[old_range_idx].clone(); + let old_range_nodes = &mut self.range_nodes[old_range_idx]; + debug_assert_eq!(old_idx, old_range.start); + debug_assert!(old_range.start + changed <= old_range.end); + debug_assert!(old_range_nodes.len() >= changed); + + old_range_nodes.truncate(changed); + core::mem::take(old_range_nodes) + }; + + let left = nodes.last().map_or(EMPTY_WORD, |node| node.root); + let node = RangeNode::new(left, changed_root); + let root = node.root; + nodes.push(node); + + (nodes, root, 1) + } + + #[cfg(test)] + pub(super) fn range_roots(&self) -> &[Word] { + &self.range_roots[..self.range_len] + } + + #[cfg(test)] + pub(super) fn range_nodes(&self) -> &[Vec] { + &self.range_nodes[..self.range_len] + } + + #[cfg(test)] + pub(super) fn belt_nodes(&self) -> &[BeltNode] { + &self.belt_nodes[..self.range_len] + } + + #[cfg(test)] + pub(super) fn storage_lengths(&self) -> [usize; 4] { + [ + self.ranges.len(), + self.range_roots.len(), + self.range_nodes.len(), + self.belt_nodes.len(), + ] + } + + pub(super) fn root(&self) -> Word { + if self.range_len == 0 { + EMPTY_WORD + } else { + self.belt_nodes[self.range_len - 1].root + } + } +} + +impl PartialEq for BeltBaggingState { + fn eq(&self, other: &Self) -> bool { + self.num_leaves == other.num_leaves + && self.range_len == other.range_len + && self.ranges.get(..self.range_len) == other.ranges.get(..other.range_len) + && self.range_roots.get(..self.range_len) == other.range_roots.get(..other.range_len) + && self.range_nodes.get(..self.range_len) == other.range_nodes.get(..other.range_len) + && self.belt_nodes.get(..self.range_len) == other.belt_nodes.get(..other.range_len) + } +} + +impl Eq for BeltBaggingState {} + +impl Default for BeltBaggingState { + fn default() -> Self { + Self { + num_leaves: 0, + range_len: 0, + ranges: Vec::new(), + range_roots: Vec::new(), + range_nodes: Vec::new(), + belt_nodes: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct RangeNode { + pub(super) left: Word, + pub(super) right: Word, + pub(super) root: Word, +} + +impl RangeNode { + fn new(left: Word, right: Word) -> Self { + Self { + left, + right, + root: Poseidon2::merge(&[left, right]), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct BeltNode { + pub(super) left: Word, + pub(super) right: Word, + pub(super) root: Word, +} + +impl BeltNode { + fn new(left: Word, right: Word) -> Self { + Self { + left, + right, + root: Poseidon2::merge(&[left, right]), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct ChangedMountain { + pub(super) mountain: ShapeMountain, + pub(super) root: Word, +} + +impl ChangedMountain { + pub(super) fn new(start: usize, height: usize, root: Word) -> Self { + Self { + mountain: ShapeMountain { start, height }, + root, + } + } + + pub(super) fn from_mountain(mountain: &BeltMountain) -> Self { + Self::new(mountain.start, mountain.height, mountain.root) + } + + pub(super) fn contains_position(&self, position: usize) -> bool { + self.mountain.contains_position(position) + } +} + +fn append_shape_recompute_index_trusted( + old_num_leaves: usize, + changed: &[ChangedMountain], +) -> usize { + let old_shape_len = shape_len_for_num_leaves(old_num_leaves); + debug_assert!(old_shape_len > 0); + + let Some(merged) = changed + .iter() + .map(|changed| changed.mountain) + .find(|mountain| mountain.height > 0) + else { + return old_shape_len - 1; + }; + + let child_height = merged.height - 1; + let left = ShapeMountain { + start: merged.start, + height: child_height, + }; + let left_idx = shape_mountain_index_for_num_leaves(old_num_leaves, left) + .expect("merged left child must be present in the old shape"); + if left_idx == 0 { + return 0; + } + + let old_split = shape_range_split_after_num_leaves(old_num_leaves, left_idx - 1) + .expect("old split before merged child must exist"); + let previous_left = + shape_mountain_at_index(old_num_leaves, left_idx - 1).expect("left neighbor must exist"); + let previous_previous_left = if left_idx >= 2 { + Some( + shape_mountain_at_index(old_num_leaves, left_idx - 2) + .expect("previous left neighbor must exist") + .height, + ) + } else { + None + }; + let new_split = shape_range_split(previous_left.height, merged.height, previous_previous_left); + if old_split == new_split { left_idx } else { left_idx - 1 } +} + +fn append_changed_ranges_trusted( + new_num_leaves: usize, + suffix_start: usize, + changed: &[ChangedMountain], +) -> AppendChangedRanges { + let new_shape_len = shape_len_for_num_leaves(new_num_leaves); + debug_assert!(!changed.is_empty()); + debug_assert!(changed.len() <= 2); + debug_assert!(suffix_start < new_shape_len); + + let mut changed_indices = [0usize; 2]; + let mut changed_len = 0; + for changed in changed { + let idx = shape_mountain_index_for_num_leaves(new_num_leaves, changed.mountain) + .expect("changed append mountain must exist in the new shape"); + debug_assert!(idx >= suffix_start); + changed_indices[changed_len] = idx; + changed_len += 1; + } + + if changed_len == 2 && changed_indices[0] > changed_indices[1] { + changed_indices.swap(0, 1); + } + debug_assert!(changed_len == 1 || changed_indices[0] != changed_indices[1]); + debug_assert_eq!(changed_indices[changed_len - 1], new_shape_len - 1); + + let mut ranges = AppendChangedRanges::new(); + let mut start = suffix_start; + for &end in &changed_indices[..changed_len] { + debug_assert!(end >= start); + ranges.push(start..end + 1).expect("append changes at most two ranges"); + start = end + 1; + } + debug_assert_eq!(start, new_shape_len); + + ranges +} + +fn changed_root_for_range_trusted( + new_num_leaves: usize, + range: &Range, + changed: &[ChangedMountain], +) -> Word { + let changed_mountain = shape_mountain_at_index(new_num_leaves, range.end - 1) + .expect("changed range must end at a mountain"); + changed + .iter() + .find(|changed| changed.mountain == changed_mountain) + .expect("changed range must end at an append-changed mountain") + .root +} + +#[cfg(test)] +pub(super) fn append_shape_in_place( + shape: &mut Vec, + old_num_leaves: usize, + changed: &[ChangedMountain], +) -> Result<(), MmrError> { + let leaf = ShapeMountain { start: old_num_leaves, height: 0 }; + let merged = changed + .iter() + .map(|changed| changed.mountain) + .find(|mountain| mountain.height > 0); + let leaf_expected = merged.is_none_or(|merged| !merged.contains_position(old_num_leaves)); + let expected_len = usize::from(merged.is_some()) + usize::from(leaf_expected); + if changed.len() != expected_len + || (leaf_expected && !changed.iter().any(|changed| changed.mountain == leaf)) + || changed.iter().any(|changed| { + Some(changed.mountain) != merged && !(leaf_expected && changed.mountain == leaf) + }) + { + return Err(MmrError::InvalidUpdate); + } + + let Some(merged) = merged else { + shape.push(leaf); + return Ok(()); + }; + + let child_height = merged.height - 1; + let left = ShapeMountain { + start: merged.start, + height: child_height, + }; + let right = ShapeMountain { + start: merged.start + (1usize << child_height), + height: child_height, + }; + let left_idx = + shape_mountain_index_for_num_leaves(old_num_leaves, left).ok_or(MmrError::InvalidUpdate)?; + if leaf_expected { + let right_idx = left_idx + 1; + let old_len = shape.len(); + if shape.get(right_idx).copied() != Some(right) { + return Err(MmrError::InvalidUpdate); + } + + shape[left_idx] = merged; + if right_idx + 1 < old_len { + shape.copy_within(right_idx + 1..old_len, right_idx); + } + shape[old_len - 1] = leaf; + } else { + if shape.get(left_idx).copied() != Some(left) || left_idx + 1 != shape.len() { + return Err(MmrError::InvalidUpdate); + } + + shape[left_idx] = merged; + } + + Ok(()) +} diff --git a/miden-crypto/src/merkle/mmr/belt/delta.rs b/miden-crypto/src/merkle/mmr/belt/delta.rs new file mode 100644 index 0000000000..fad5e29f59 --- /dev/null +++ b/miden-crypto/src/merkle/mmr/belt/delta.rs @@ -0,0 +1,315 @@ +use alloc::{collections::BTreeMap, vec::Vec}; + +use super::proof::*; +use super::shape::*; +use super::{BeltProof, BeltSummary, MmrError}; +use crate::{Word, hash::poseidon2::Poseidon2}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MmrBeltDelta { + pub(super) from_num_leaves: usize, + pub(super) to_num_leaves: usize, + pub(super) new_tail_peaks: Vec, + pub(super) merge_auth: BTreeMap<(usize, usize), Word>, +} + +impl MmrBeltDelta { + pub fn from_parts( + from_num_leaves: usize, + to_num_leaves: usize, + new_tail_peaks: Vec, + merge_auth_nodes: I, + ) -> Result + where + I: IntoIterator, + { + if from_num_leaves > to_num_leaves { + return Err(MmrError::ForestOutOfBounds(from_num_leaves, to_num_leaves)); + } + + let common = common_peak_prefix_len(from_num_leaves, to_num_leaves); + let new_len = shape_len_for_num_leaves(to_num_leaves); + if common + new_tail_peaks.len() != new_len { + return Err(MmrError::InvalidUpdate); + } + + let mut merge_auth = BTreeMap::new(); + for ((start, height), value) in merge_auth_nodes { + if merge_auth.insert((start, height), value).is_some() { + return Err(MmrError::InvalidUpdate); + } + } + + Ok(Self { + from_num_leaves, + to_num_leaves, + new_tail_peaks, + merge_auth, + }) + } + + pub fn from_num_leaves(&self) -> usize { + self.from_num_leaves + } + + pub fn to_num_leaves(&self) -> usize { + self.to_num_leaves + } + + pub fn new_tail_peaks(&self) -> &[Word] { + &self.new_tail_peaks + } + + pub fn merge_auth_nodes(&self) -> impl Iterator + '_ { + self.merge_auth + .iter() + .map(|(&(start, height), &value)| ((start, height), value)) + } + + pub fn num_merge_auth_nodes(&self) -> usize { + self.merge_auth.len() + } + + pub fn verify_transition( + &self, + old_summary: &BeltSummary, + new_summary: &BeltSummary, + ) -> Result { + if old_summary.num_leaves() != self.from_num_leaves + || new_summary.num_leaves() != self.to_num_leaves + { + return Ok(false); + } + + let old_shape = shape_mountains(self.from_num_leaves); + let new_shape = shape_mountains(self.to_num_leaves); + let common = common_peak_prefix_len_from_shapes(&old_shape, &new_shape); + + let roots = self.apply_with_common(common, old_summary.roots())?; + let derived = BeltSummary::from_roots(self.to_num_leaves, &roots)?; + + Ok(&derived == new_summary + && self.authenticates_absorbed_roots( + old_summary, + new_summary, + &old_shape, + &new_shape, + common, + )?) + } + + fn authenticates_absorbed_roots( + &self, + old_summary: &BeltSummary, + new_summary: &BeltSummary, + old_shape: &[ShapeMountain], + new_shape: &[ShapeMountain], + common: usize, + ) -> Result { + for (old_mountain, &old_root) in old_shape.iter().zip(old_summary.roots()).skip(common) { + let new_idx = shape_mountain_for_position(new_shape, old_mountain.start) + .ok_or(MmrError::InvalidUpdate)?; + let new_mountain = new_shape[new_idx]; + let old_end = old_mountain + .start + .checked_add(old_mountain.size()) + .and_then(|end| end.checked_sub(1)) + .ok_or(MmrError::InvalidUpdate)?; + if new_mountain.height < old_mountain.height || !new_mountain.contains_position(old_end) + { + return Err(MmrError::InvalidUpdate); + } + + let mut root = old_root; + for (side, sibling_start, height) in + climb_to_peak(old_mountain.start, old_mountain.height, new_mountain.height) + { + let &sibling = + self.merge_auth.get(&(sibling_start, height)).ok_or(MmrError::InvalidUpdate)?; + root = match side { + SiblingSide::Left => Poseidon2::merge(&[sibling, root]), + SiblingSide::Right => Poseidon2::merge(&[root, sibling]), + }; + } + + if root != new_summary.roots()[new_idx] { + return Ok(false); + } + } + + Ok(true) + } + + pub fn apply(&self, old_peaks: &[Word]) -> Result, MmrError> { + let common = common_peak_prefix_len(self.from_num_leaves, self.to_num_leaves); + self.apply_with_common(common, old_peaks) + } + + fn apply_with_common(&self, common: usize, old_peaks: &[Word]) -> Result, MmrError> { + let old_len = shape_len_for_num_leaves(self.from_num_leaves); + if old_peaks.len() != old_len { + return Err(MmrError::InvalidPeaks(format!( + "expected {old_len} peaks for {} leaves but got {}", + self.from_num_leaves, + old_peaks.len() + ))); + } + + let new_len = shape_len_for_num_leaves(self.to_num_leaves); + if common + self.new_tail_peaks.len() != new_len { + return Err(MmrError::InvalidUpdate); + } + + let mut peaks = old_peaks[..common].to_vec(); + peaks.extend_from_slice(&self.new_tail_peaks); + + Ok(peaks) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PartialMmrBelt { + num_leaves: usize, + peaks: Vec, + tracked: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TrackedLeaf { + leaf: Word, + mountain_start: usize, + mountain_height: usize, + within_path: Vec, +} + +impl PartialMmrBelt { + pub fn from_peaks(num_leaves: usize, peaks: Vec) -> Result { + let expected = shape_len_for_num_leaves(num_leaves); + if peaks.len() != expected { + return Err(MmrError::InvalidPeaks(format!( + "expected {expected} peaks for {num_leaves} leaves but got {}", + peaks.len() + ))); + } + + Ok(Self { + num_leaves, + peaks, + tracked: BTreeMap::new(), + }) + } + + pub fn num_leaves(&self) -> usize { + self.num_leaves + } + + pub fn peaks(&self) -> &[Word] { + &self.peaks + } + + pub fn summary(&self) -> BeltSummary { + BeltSummary::from_roots(self.num_leaves, &self.peaks) + .expect("partial mountain-order summary must match the current shape") + } + + pub fn is_tracked(&self, pos: usize) -> bool { + self.tracked.contains_key(&pos) + } + + pub fn num_tracked(&self) -> usize { + self.tracked.len() + } + + pub fn get(&self, pos: usize) -> Option { + self.tracked.get(&pos).map(|tracked| tracked.leaf) + } + + pub fn track(&mut self, proof: &BeltProof) -> Result<(), MmrError> { + if !proof.verify(&self.summary()) { + return Err(MmrError::PeakPathMismatch); + } + + let shape = shape_mountains(self.num_leaves); + let mountain_idx = shape_mountain_for_position(&shape, proof.position()) + .ok_or(MmrError::PositionNotFound(proof.position()))?; + let mountain = shape[mountain_idx]; + + self.tracked.insert( + proof.position(), + TrackedLeaf { + leaf: proof.leaf(), + mountain_start: mountain.start, + mountain_height: mountain.height, + within_path: proof.nodes[..mountain.height].to_vec(), + }, + ); + + Ok(()) + } + + pub fn untrack(&mut self, pos: usize) -> bool { + self.tracked.remove(&pos).is_some() + } + + pub fn open(&self, pos: usize) -> Result, MmrError> { + let Some(tracked) = self.tracked.get(&pos) else { + return Ok(None); + }; + + let shape = shape_mountains(self.num_leaves); + let mountain_idx = + shape_mountain_for_position(&shape, pos).ok_or(MmrError::PositionNotFound(pos))?; + + let mut nodes = tracked.within_path.clone(); + nodes.extend(bagging_path_nodes(&self.peaks, &shape_ranges(&shape), mountain_idx)); + + Ok(Some(BeltProof { position: pos, leaf: tracked.leaf, nodes })) + } + + pub fn apply_verified( + &mut self, + delta: &MmrBeltDelta, + new_summary: &BeltSummary, + ) -> Result<(), MmrError> { + if !delta.verify_transition(&self.summary(), new_summary)? { + return Err(MmrError::InvalidUpdate); + } + + let mut updated = self.clone(); + updated.apply(delta)?; + *self = updated; + + Ok(()) + } + + pub fn apply(&mut self, delta: &MmrBeltDelta) -> Result<(), MmrError> { + if delta.from_num_leaves() != self.num_leaves { + return Err(MmrError::InvalidUpdate); + } + + self.peaks = delta.apply(&self.peaks)?; + self.num_leaves = delta.to_num_leaves(); + + let new_shape = shape_mountains(self.num_leaves); + for (&pos, tracked) in self.tracked.iter_mut() { + let mountain_idx = shape_mountain_for_position(&new_shape, pos) + .ok_or(MmrError::PositionNotFound(pos))?; + let new_mountain = new_shape[mountain_idx]; + + for (side, sibling_start, height) in + climb_to_peak(tracked.mountain_start, tracked.mountain_height, new_mountain.height) + { + let &value = delta + .merge_auth + .get(&(sibling_start, height)) + .ok_or(MmrError::InvalidUpdate)?; + tracked.within_path.push(BeltProofNode { value, side }); + } + + tracked.mountain_start = new_mountain.start; + tracked.mountain_height = new_mountain.height; + } + + Ok(()) + } +} diff --git a/miden-crypto/src/merkle/mmr/belt/mod.rs b/miden-crypto/src/merkle/mmr/belt/mod.rs new file mode 100644 index 0000000000..156b6db9de --- /dev/null +++ b/miden-crypto/src/merkle/mmr/belt/mod.rs @@ -0,0 +1,591 @@ +use alloc::{collections::BTreeMap, vec::Vec}; + +use super::{Forest, MmrError}; +use crate::{EMPTY_WORD, Word, hash::poseidon2::Poseidon2}; + +mod bagging; +mod delta; +mod proof; +mod shape; +use bagging::*; +pub use delta::{MmrBeltDelta, PartialMmrBelt}; +pub use proof::BeltProof; +use proof::{BeltProofNode, bagging_path_nodes}; +use shape::*; + +/// Experimental Merkle Mountain Belt prototype. +#[derive(Debug, Clone, Default)] +pub struct MmrBelt { + mountains: Vec>, + hashes: BeltHashArray, + bagging: BeltBaggingState, + last_bagging_update_hashes: usize, + free_mountain_slots: Vec, + head: Option, + tail: Option, + rightmost_mergeable: Option, + num_leaves: usize, +} + +impl MmrBelt { + pub fn new() -> Self { + Self::default() + } + + pub fn add(&mut self, leaf: Word) -> Result { + self.add_with_bagging_mode(leaf, true) + } + + /// Benchmark-only append that skips live summary maintenance. + /// + /// This leaves [`Self::summary`] and [`Self::commitment_root`] stale. Rebuild a diagnostic + /// summary with [`BeltSummary::from_roots`] after calling this method. + #[cfg(any(test, feature = "internal"))] + pub fn add_without_bagging_for_benchmark(&mut self, leaf: Word) -> Result { + self.add_with_bagging_mode(leaf, false) + } + + fn add_with_bagging_mode( + &mut self, + leaf: Word, + refresh_bagging: bool, + ) -> Result { + if self.num_leaves >= Forest::MAX_LEAVES { + return Err(MmrError::ForestSizeExceeded { + requested: self.num_leaves.saturating_add(1), + max: Forest::MAX_LEAVES, + }); + } + + let old_num_leaves = self.num_leaves; + let leaf_position = self.num_leaves; + let leaf_index = leaf_hash_index(leaf_position); + self.hashes.set(leaf_index, leaf); + + let new_idx = self.push_mountain(BeltMountain::new(leaf_position, leaf_index, leaf)); + self.num_leaves += 1; + if let Some(prev_idx) = self.mountain_slot(new_idx).prev { + self.track_mergeable_pair(prev_idx, new_idx); + } + + let merged = if let Some(right_idx) = self.rightmost_mergeable { + Some(self.merge_pair(right_idx)) + } else { + None + }; + let num_merges = usize::from(merged.is_some()); + + if refresh_bagging { + let leaf_change = ChangedMountain::new(leaf_position, 0, leaf); + let mut changed = [leaf_change; 2]; + let mut changed_len = 0; + + if let Some(merged) = merged { + changed[changed_len] = merged; + changed_len += 1; + if !merged.contains_position(leaf_position) { + changed[changed_len] = leaf_change; + changed_len += 1; + } + } else { + changed[changed_len] = leaf_change; + changed_len += 1; + } + + self.refresh_bagging_state(old_num_leaves, &changed[..changed_len]); + } + + Ok(num_merges) + } + + #[cfg(test)] + fn mountain_heights(&self) -> Vec { + self.ordered_mountains().iter().map(|mountain| mountain.height).collect() + } + + #[cfg(test)] + fn storage_slots_for_testing(&self) -> usize { + self.mountains.len() + } + + #[cfg(test)] + fn rightmost_mergeable_pair_for_testing(&self) -> Option<(usize, usize)> { + let right_idx = self.rightmost_mergeable?; + let right = self.mountain_slot(right_idx); + let left_idx = right.prev?; + Some((self.mountain_slot(left_idx).mountain.start, right.mountain.start)) + } + + #[cfg(test)] + fn live_range_roots_for_testing(&self) -> &[Word] { + self.bagging.range_roots() + } + + #[cfg(test)] + fn live_commitment_root_for_testing(&self) -> Word { + self.bagging.root() + } + + #[cfg(test)] + fn bagging_storage_lengths_for_testing(&self) -> [usize; 4] { + self.bagging.storage_lengths() + } + + #[cfg(test)] + fn last_bagging_update_hashes_for_testing(&self) -> usize { + self.last_bagging_update_hashes + } + + pub fn num_leaves(&self) -> usize { + self.num_leaves + } + + pub fn peaks(&self) -> Vec { + self.ordered_mountains().iter().map(|mountain| mountain.root).collect() + } + + pub fn commitment_root(&self) -> Word { + self.bagging.root() + } + + pub fn summary(&self) -> BeltSummary { + BeltSummary::from_roots_and_bagging(self.num_leaves, self.peaks(), &self.bagging) + .expect("live mountain-order summary must match the current shape") + } + + pub fn delta(&self, from_num_leaves: usize) -> Result { + if from_num_leaves > self.num_leaves { + return Err(MmrError::ForestOutOfBounds(from_num_leaves, self.num_leaves)); + } + + let common = common_peak_prefix_len(from_num_leaves, self.num_leaves); + let peaks = self.peaks(); + + let from_shape = shape_mountains(from_num_leaves); + let to_shape = shape_mountains(self.num_leaves); + let mut merge_auth = BTreeMap::new(); + + for absorbed in &from_shape[common..] { + let to_idx = shape_mountain_for_position(&to_shape, absorbed.start) + .expect("absorbed leaves still exist in the new state"); + let to_height = to_shape[to_idx].height; + + for (_, sibling_start, height) in + climb_to_peak(absorbed.start, absorbed.height, to_height) + { + merge_auth + .entry((sibling_start, height)) + .or_insert_with(|| self.node_at(sibling_start, height)); + } + } + + Ok(MmrBeltDelta { + from_num_leaves, + to_num_leaves: self.num_leaves, + new_tail_peaks: peaks[common..].to_vec(), + merge_auth, + }) + } + + fn node_at(&self, start: usize, height: usize) -> Word { + self.hash_at(node_hash_index(start, height)) + } + + fn hash_at(&self, index: HashIndex) -> Word { + self.hashes.get(index).expect("hash must be present in storage") + } + + fn refresh_bagging_state(&mut self, old_num_leaves: usize, changed: &[ChangedMountain]) { + let hashes = self.bagging.append_update(old_num_leaves, self.num_leaves, changed); + self.last_bagging_update_hashes = hashes; + } + + pub fn open(&self, position: usize) -> Result { + if position >= self.num_leaves { + return Err(MmrError::PositionNotFound(position)); + } + + let mountains = self.ordered_mountains(); + let shape = shape_from_mountains(&mountains); + let mountain_idx = shape_mountain_for_position(&shape, position) + .ok_or(MmrError::PositionNotFound(position))?; + let mountain = shape[mountain_idx]; + let (leaf, mut nodes) = self.open_within_mountain(position, mountain); + + let peaks = mountains.iter().map(|mountain| mountain.root).collect::>(); + nodes.extend(bagging_path_nodes(&peaks, &shape_ranges(&shape), mountain_idx)); + + Ok(BeltProof { position, leaf, nodes }) + } + + fn open_within_mountain( + &self, + position: usize, + mountain: ShapeMountain, + ) -> (Word, Vec) { + let leaf = self.node_at(position, 0); + let nodes = climb_to_peak(position, 0, mountain.height) + .map(|(side, sibling_start, height)| BeltProofNode { + value: self.node_at(sibling_start, height), + side, + }) + .collect(); + + (leaf, nodes) + } + + #[cfg(test)] + fn range_heights(&self) -> Vec> { + let mountains = self.ordered_mountains(); + let shape = shape_from_mountains(&mountains); + shape_ranges(&shape) + .into_iter() + .map(|range| shape[range].iter().map(|mountain| mountain.height).collect()) + .collect() + } + + fn push_mountain(&mut self, mountain: BeltMountain) -> usize { + let slot = BeltMountainSlot { + mountain, + prev: self.tail, + next: None, + mergeable_prev: None, + mergeable_next: None, + in_mergeable_list: false, + }; + + let idx = if let Some(idx) = self.free_mountain_slots.pop() { + self.mountains[idx] = Some(slot); + idx + } else { + let idx = self.mountains.len(); + self.mountains.push(Some(slot)); + idx + }; + + if let Some(tail_idx) = self.tail { + self.mountain_slot_mut(tail_idx).next = Some(idx); + } else { + self.head = Some(idx); + } + self.tail = Some(idx); + + idx + } + + fn merge_pair(&mut self, right_idx: usize) -> ChangedMountain { + let left_idx = self + .mountain_slot(right_idx) + .prev + .expect("right member of mergeable pair must have left neighbor"); + let right_next = self.mountain_slot(right_idx).next; + let left_prev = self.mountain_slot(left_idx).prev; + let insert_after = if self.mountain_slot(left_idx).in_mergeable_list { + self.mountain_slot(left_idx).mergeable_prev + } else { + self.mountain_slot(right_idx).mergeable_prev + }; + + self.untrack_mergeable_pair(right_idx); + self.untrack_mergeable_pair(left_idx); + + let right = self.mountains[right_idx] + .take() + .expect("right member of mergeable pair must be active"); + let left = self.mountains[left_idx] + .take() + .expect("left member of mergeable pair must be active"); + let root = Poseidon2::merge(&[left.mountain.root, right.mountain.root]); + let merged = left.mountain.merge(right.mountain, root); + let changed = ChangedMountain::from_mountain(&merged); + self.hashes.set(merged.index, root); + + self.mountains[left_idx] = Some(BeltMountainSlot { + mountain: merged, + prev: left_prev, + next: right_next, + mergeable_prev: None, + mergeable_next: None, + in_mergeable_list: false, + }); + + if let Some(next_idx) = right_next { + self.mountain_slot_mut(next_idx).prev = Some(left_idx); + } else { + self.tail = Some(left_idx); + } + self.free_mountain_slots.push(right_idx); + + if let Some(prev_idx) = left_prev { + self.track_mergeable_pair_after(insert_after, prev_idx, left_idx); + } + if let Some(next_idx) = right_next { + self.track_mergeable_pair_after(self.rightmost_mergeable, left_idx, next_idx); + } + + changed + } + + fn track_mergeable_pair(&mut self, left_idx: usize, right_idx: usize) { + self.track_mergeable_pair_after(self.rightmost_mergeable, left_idx, right_idx); + } + + fn track_mergeable_pair_after( + &mut self, + prev_pair: Option, + left_idx: usize, + right_idx: usize, + ) { + if !self.is_mergeable_pair(left_idx, right_idx) + || self.mountain_slot(right_idx).in_mergeable_list + { + return; + } + + if let Some(prev_idx) = prev_pair { + self.mountain_slot_mut(prev_idx).mergeable_next = Some(right_idx); + } + + let slot = self.mountain_slot_mut(right_idx); + slot.mergeable_prev = prev_pair; + slot.mergeable_next = None; + slot.in_mergeable_list = true; + self.rightmost_mergeable = Some(right_idx); + } + + fn untrack_mergeable_pair(&mut self, right_idx: usize) { + let Some(slot) = self.mountains.get(right_idx).and_then(Option::as_ref) else { + return; + }; + if !slot.in_mergeable_list { + return; + } + + let prev = slot.mergeable_prev; + let next = slot.mergeable_next; + + if let Some(prev_idx) = prev { + self.mountain_slot_mut(prev_idx).mergeable_next = next; + } + if let Some(next_idx) = next { + self.mountain_slot_mut(next_idx).mergeable_prev = prev; + } else { + self.rightmost_mergeable = prev; + } + + let slot = self.mountain_slot_mut(right_idx); + slot.mergeable_prev = None; + slot.mergeable_next = None; + slot.in_mergeable_list = false; + } + + fn is_mergeable_pair(&self, left_idx: usize, right_idx: usize) -> bool { + let Some(left_slot) = self.mountains.get(left_idx).and_then(Option::as_ref) else { + return false; + }; + let Some(right_slot) = self.mountains.get(right_idx).and_then(Option::as_ref) else { + return false; + }; + + left_slot.next == Some(right_idx) + && right_slot.prev == Some(left_idx) + && left_slot.mountain.height == right_slot.mountain.height + } + + fn ordered_mountains(&self) -> Vec<&BeltMountain> { + let mut mountains = Vec::new(); + let mut next = self.head; + while let Some(idx) = next { + let slot = self.mountain_slot(idx); + mountains.push(&slot.mountain); + next = slot.next; + } + mountains + } + + fn mountain_slot(&self, idx: usize) -> &BeltMountainSlot { + self.mountains[idx].as_ref().expect("mountain slot must be active") + } + + fn mountain_slot_mut(&mut self, idx: usize) -> &mut BeltMountainSlot { + self.mountains[idx].as_mut().expect("mountain slot must be active") + } +} + +#[derive(Debug, Clone)] +struct BeltMountainSlot { + mountain: BeltMountain, + prev: Option, + next: Option, + mergeable_prev: Option, + mergeable_next: Option, + in_mergeable_list: bool, +} + +#[derive(Debug, Clone, Default)] +struct BeltHashArray { + nodes: Vec, + present: Vec, +} + +impl BeltHashArray { + fn get(&self, index: HashIndex) -> Option { + self.is_present(index).then(|| self.nodes[index.0]) + } + + fn set(&mut self, index: HashIndex, value: Word) { + if self.nodes.len() <= index.0 { + self.nodes.resize(index.0 + 1, EMPTY_WORD); + } + let present_word = index.0 / u64::BITS as usize; + if self.present.len() <= present_word { + self.present.resize(present_word + 1, 0); + } + + self.nodes[index.0] = value; + self.present[present_word] |= 1u64 << (index.0 % u64::BITS as usize); + } + + fn is_present(&self, index: HashIndex) -> bool { + let present_word = index.0 / u64::BITS as usize; + self.present + .get(present_word) + .is_some_and(|word| word & (1u64 << (index.0 % u64::BITS as usize)) != 0) + } + + #[cfg(test)] + fn clear(&mut self) { + self.nodes.clear(); + self.present.clear(); + } + + #[cfg(test)] + fn slot_count(&self) -> usize { + self.nodes.len() + } + + #[cfg(test)] + fn live_count(&self) -> usize { + self.present.iter().map(|word| word.count_ones() as usize).sum() + } + + #[cfg(test)] + fn value_slot_bytes_for_testing() -> usize { + core::mem::size_of::() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BeltSummary { + pub(super) num_leaves: usize, + roots: Vec, + commitment_root: Word, + #[cfg(test)] + range_roots: Vec, +} + +impl BeltSummary { + pub fn from_roots(num_leaves: usize, roots: &[Word]) -> Result { + let bagging = BeltBaggingState::from_roots(num_leaves, roots)?; + + Ok(Self { + num_leaves, + roots: roots.to_vec(), + commitment_root: bagging.root(), + #[cfg(test)] + range_roots: bagging.range_roots().to_vec(), + }) + } + + pub fn from_peaks(num_leaves: usize, peaks: &[Word]) -> Result { + Self::from_roots(num_leaves, peaks) + } + + fn from_roots_and_bagging( + num_leaves: usize, + roots: Vec, + bagging: &BeltBaggingState, + ) -> Result { + let expected = shape_len_for_num_leaves(num_leaves); + if roots.len() != expected { + return Err(MmrError::InvalidPeaks(format!( + "expected {expected} peaks for {num_leaves} leaves but got {}", + roots.len() + ))); + } + + Ok(Self { + num_leaves, + roots, + commitment_root: bagging.root(), + #[cfg(test)] + range_roots: bagging.range_roots().to_vec(), + }) + } + + pub fn num_leaves(&self) -> usize { + self.num_leaves + } + + pub fn roots(&self) -> &[Word] { + &self.roots + } + + pub fn commitment_root(&self) -> Word { + self.commitment_root + } + + pub fn root(&self) -> Word { + self.commitment_root() + } + + #[cfg(test)] + fn range_roots_for_testing(&self) -> &[Word] { + &self.range_roots + } +} + +#[derive(Debug, Clone)] +pub(super) struct BeltMountain { + pub(super) start: usize, + pub(super) height: usize, + index: HashIndex, + pub(super) root: Word, +} + +impl BeltMountain { + fn new(start: usize, index: HashIndex, root: Word) -> Self { + Self { start, height: 0, index, root } + } + + fn merge(self, other: Self, root: Word) -> Self { + debug_assert_eq!(self.height, other.height); + debug_assert_eq!(self.start + self.size(), other.start); + + let index = parent_hash_index(self.index); + debug_assert_eq!(index, parent_hash_index(other.index)); + Self { + start: self.start, + height: self.height + 1, + index, + root, + } + } + + fn size(&self) -> usize { + 1usize << self.height + } +} + +fn shape_from_mountains(mountains: &[&BeltMountain]) -> Vec { + mountains + .iter() + .map(|mountain| ShapeMountain { + start: mountain.start, + height: mountain.height, + }) + .collect() +} + +#[cfg(test)] +mod tests; diff --git a/miden-crypto/src/merkle/mmr/belt/proof.rs b/miden-crypto/src/merkle/mmr/belt/proof.rs new file mode 100644 index 0000000000..d9179df2e4 --- /dev/null +++ b/miden-crypto/src/merkle/mmr/belt/proof.rs @@ -0,0 +1,143 @@ +use alloc::vec::Vec; +use core::ops::Range; + +use super::BeltSummary; +use super::shape::*; +use crate::{Word, hash::poseidon2::Poseidon2}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BeltProof { + pub(super) position: usize, + pub(super) leaf: Word, + pub(super) nodes: Vec, +} + +impl BeltProof { + pub fn position(&self) -> usize { + self.position + } + + pub fn leaf(&self) -> Word { + self.leaf + } + + pub fn verify(&self, summary: &BeltSummary) -> bool { + if self.position >= summary.num_leaves { + return false; + } + + let Some(steps) = proof_steps_for_position(summary.num_leaves, self.position) else { + return false; + }; + + if self.nodes.len() != steps.len() + || self.nodes.iter().zip(&steps).any(|(node, side)| node.side != *side) + { + return false; + } + + let root = + self.nodes + .iter() + .zip(&steps) + .fold(self.leaf, |current, (node, side)| match side { + SiblingSide::Left => Poseidon2::merge(&[node.value, current]), + SiblingSide::Right => Poseidon2::merge(&[current, node.value]), + }); + + root == summary.commitment_root() + } + + #[cfg(test)] + pub(super) fn set_leaf_for_testing(&mut self, leaf: Word) { + self.leaf = leaf; + } + + #[cfg(test)] + pub(super) fn set_position_for_testing(&mut self, position: usize) { + self.position = position; + } + + #[cfg(test)] + pub(super) fn tamper_node_value_for_testing(&mut self, index: usize, value: Word) { + self.nodes[index].value = value; + } + + #[cfg(test)] + pub(super) fn node_count_for_testing(&self) -> usize { + self.nodes.len() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct BeltProofNode { + pub(super) value: Word, + pub(super) side: SiblingSide, +} + +pub(super) fn bagging_path_nodes( + peaks: &[Word], + ranges: &[Range], + mountain_idx: usize, +) -> Vec { + let range_idx = ranges + .iter() + .position(|range| range.contains(&mountain_idx)) + .expect("mountain must be part of a range"); + let range = ranges[range_idx].clone(); + + let mut nodes = Vec::new(); + + let prefix = bag_range(&peaks[range.start..mountain_idx]); + nodes.push(BeltProofNode { value: prefix, side: SiblingSide::Left }); + for &peak in &peaks[mountain_idx + 1..range.end] { + nodes.push(BeltProofNode { value: peak, side: SiblingSide::Right }); + } + + // Exclude the proven range root from the belt path. + let range_root = |range: &Range| bag_range(&peaks[range.clone()]); + let prefix_belt = bag_belt(&ranges[..range_idx].iter().map(range_root).collect::>()); + nodes.push(BeltProofNode { + value: prefix_belt, + side: SiblingSide::Left, + }); + for range in &ranges[range_idx + 1..] { + nodes.push(BeltProofNode { + value: range_root(range), + side: SiblingSide::Right, + }); + } + + nodes +} + +pub(super) fn proof_steps_for_position( + num_leaves: usize, + position: usize, +) -> Option> { + if position >= num_leaves { + return None; + } + + let shape = shape_mountains(num_leaves); + let mountain_idx = shape_mountain_for_position(&shape, position)?; + let mountain = shape[mountain_idx]; + + let mut steps = balanced_tree_sides(mountain.height, position - mountain.start); + + let ranges = shape_ranges(&shape); + let range_idx = ranges.iter().position(|range| range.contains(&mountain_idx))?; + let range = ranges[range_idx].clone(); + + steps.push(SiblingSide::Left); + for _ in &shape[mountain_idx + 1..range.end] { + steps.push(SiblingSide::Right); + } + + steps.push(SiblingSide::Left); + for _ in (range_idx + 1)..ranges.len() { + steps.push(SiblingSide::Right); + } + + Some(steps) +} diff --git a/miden-crypto/src/merkle/mmr/belt/shape.rs b/miden-crypto/src/merkle/mmr/belt/shape.rs new file mode 100644 index 0000000000..0a19320741 --- /dev/null +++ b/miden-crypto/src/merkle/mmr/belt/shape.rs @@ -0,0 +1,302 @@ +use alloc::vec::Vec; +use core::ops::Range; + +use crate::{EMPTY_WORD, Word, hash::poseidon2::Poseidon2}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum SiblingSide { + Left, + Right, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct HashIndex(pub(super) usize); + +pub(super) fn leaf_hash_index(position: usize) -> HashIndex { + HashIndex(2 * (position + 1) + 1) +} + +pub(super) fn parent_hash_index(child: HashIndex) -> HashIndex { + let span = 1usize << (child.0.trailing_zeros() as usize + 2); + HashIndex(child.0 + child.0 % span) +} + +#[cfg(test)] +pub(super) fn hash_children(parent: HashIndex) -> (HashIndex, HashIndex) { + debug_assert!(parent.0 % 2 == 0); + let span = 1usize << (parent.0.trailing_zeros() as usize - 1); + (HashIndex(parent.0 - 3 * span), HashIndex(parent.0 - span)) +} + +pub(super) fn node_hash_index(start: usize, height: usize) -> HashIndex { + // Lemma 24 closed form for the node covering `[start, start + 2^height)`. + HashIndex((2 * (start >> height) + 3) << height) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ShapeMountain { + pub(super) start: usize, + pub(super) height: usize, +} + +impl ShapeMountain { + pub(super) fn size(&self) -> usize { + 1usize << self.height + } + + pub(super) fn contains_position(&self, position: usize) -> bool { + position >= self.start && position < self.start + self.size() + } +} + +pub(super) fn shape_mountains(num_leaves: usize) -> Vec { + if num_leaves == 0 { + return Vec::new(); + } + + let bits = num_leaves + 1; + let num_mountains = bits.ilog2() as usize; + + let mut mountains = Vec::with_capacity(num_mountains); + let mut start = 0; + for position in (0..num_mountains).rev() { + let bit = (bits >> position) & 1; + let height = position + bit; + mountains.push(ShapeMountain { start, height }); + start += 1usize << height; + } + debug_assert_eq!(start, num_leaves); + + mountains +} + +pub(super) fn shape_mountain_for_position( + mountains: &[ShapeMountain], + position: usize, +) -> Option { + mountains + .iter() + .position(|mountain| position < mountain.start + mountain.size()) +} + +#[cfg(test)] +pub(super) fn shape_mountain_index( + mountains: &[ShapeMountain], + target: ShapeMountain, +) -> Option { + mountains.iter().position(|&mountain| mountain == target) +} + +pub(super) fn shape_mountain_index_for_num_leaves( + num_leaves: usize, + target: ShapeMountain, +) -> Option { + if num_leaves == 0 { + return None; + } + + let bits = num_leaves + 1; + let num_mountains = bits.ilog2() as usize; + + let mut index = None; + if target.height < num_mountains && (bits >> target.height) & 1 == 0 { + index = shape_mountain_index_candidate(bits, num_mountains, target.height, target); + } + if index.is_none() && target.height > 0 { + let position = target.height - 1; + if position < num_mountains && (bits >> position) & 1 == 1 { + index = shape_mountain_index_candidate(bits, num_mountains, position, target); + } + } + + index +} + +pub(super) fn shape_mountain_index_candidate( + bits: usize, + num_mountains: usize, + position: usize, + target: ShapeMountain, +) -> Option { + let bit = (bits >> position) & 1; + let height = position + bit; + if height != target.height { + return None; + } + + let lower_mask = (1usize << (position + 1)) - 1; + let mountain_mask = (1usize << num_mountains) - 1; + let higher_mask = mountain_mask ^ lower_mask; + let base = (1usize << num_mountains) - (1usize << (position + 1)); + let start = base + (bits & higher_mask); + if start != target.start { + return None; + } + + Some(num_mountains - 1 - position) +} + +pub(super) fn shape_len_for_num_leaves(num_leaves: usize) -> usize { + if num_leaves == 0 { + 0 + } else { + (num_leaves + 1).ilog2() as usize + } +} + +pub(super) fn shape_mountain_at_index(num_leaves: usize, index: usize) -> Option { + if num_leaves == 0 { + return None; + } + + let bits = num_leaves + 1; + let num_mountains = bits.ilog2() as usize; + if index >= num_mountains { + return None; + } + + let position = num_mountains - 1 - index; + let bit = (bits >> position) & 1; + let height = position + bit; + let lower_mask = (1usize << (position + 1)) - 1; + let mountain_mask = (1usize << num_mountains) - 1; + let higher_mask = mountain_mask ^ lower_mask; + let base = (1usize << num_mountains) - (1usize << (position + 1)); + let start = base + (bits & higher_mask); + + Some(ShapeMountain { start, height }) +} + +#[cfg(test)] +#[allow(dead_code)] +pub(super) fn shape_range_index_for_mountain( + ranges: &[Range], + mountain_idx: usize, +) -> Option { + ranges.iter().position(|range| range.contains(&mountain_idx)) +} + +pub(super) fn shape_range_index_for_num_leaves( + num_leaves: usize, + mountain_idx: usize, +) -> Option { + let shape_len = shape_len_for_num_leaves(num_leaves); + if mountain_idx >= shape_len { + return None; + } + + let bits = num_leaves + 1; + let split_mask = bits & (!(bits << 1) | !(bits >> 1)); + let mountain_mask = (1usize << shape_len) - 1; + let prefix_mask = (1usize << (shape_len - mountain_idx)) - 1; + + Some((split_mask & (mountain_mask ^ prefix_mask)).count_ones() as usize) +} + +pub(super) fn shape_ranges(mountains: &[ShapeMountain]) -> Vec> { + if mountains.is_empty() { + return Vec::new(); + } + + let mut ranges = Vec::new(); + let mut start = 0; + for idx in 0..mountains.len() - 1 { + if shape_range_split_after(mountains, idx) { + ranges.push(start..idx + 1); + start = idx + 1; + } + } + ranges.push(start..mountains.len()); + ranges +} + +pub(super) fn shape_range_split_after(mountains: &[ShapeMountain], idx: usize) -> bool { + shape_range_split( + mountains[idx].height, + mountains[idx + 1].height, + (idx > 0).then(|| mountains[idx - 1].height), + ) +} + +pub(super) fn shape_range_split_after_num_leaves(num_leaves: usize, idx: usize) -> Option { + Some(shape_range_split( + shape_mountain_at_index(num_leaves, idx)?.height, + shape_mountain_at_index(num_leaves, idx + 1)?.height, + if idx == 0 { + None + } else { + Some(shape_mountain_at_index(num_leaves, idx - 1)?.height) + }, + )) +} + +pub(super) fn shape_range_split(left: usize, right: usize, previous_left: Option) -> bool { + let drops_by_two = left == right + 2; + let left_is_right_member_of_mergeable_pair = previous_left == Some(left); + + drops_by_two || left_is_right_member_of_mergeable_pair +} + +pub(super) fn sibling_and_parent_start(start: usize, height: usize) -> (SiblingSide, usize, usize) { + let span = 1usize << height; + if (start >> height) & 1 == 0 { + (SiblingSide::Right, start + span, start) + } else { + (SiblingSide::Left, start - span, start - span) + } +} + +pub(super) fn climb_to_peak( + start: usize, + from_height: usize, + to_height: usize, +) -> impl Iterator { + let mut start = start; + (from_height..to_height).map(move |height| { + let (side, sibling_start, parent_start) = sibling_and_parent_start(start, height); + start = parent_start; + (side, sibling_start, height) + }) +} + +pub(super) fn bag_range(peaks: &[Word]) -> Word { + peaks.iter().fold(EMPTY_WORD, |acc, &peak| Poseidon2::merge(&[acc, peak])) +} + +pub(super) fn bag_belt(range_roots: &[Word]) -> Word { + range_roots.iter().fold(EMPTY_WORD, |acc, &root| Poseidon2::merge(&[acc, root])) +} + +pub(super) fn common_peak_prefix_len(from_num_leaves: usize, to_num_leaves: usize) -> usize { + let from_shape = shape_mountains(from_num_leaves); + let to_shape = shape_mountains(to_num_leaves); + common_peak_prefix_len_from_shapes(&from_shape, &to_shape) +} + +pub(super) fn common_peak_prefix_len_from_shapes( + from_shape: &[ShapeMountain], + to_shape: &[ShapeMountain], +) -> usize { + from_shape + .iter() + .zip(to_shape.iter()) + .take_while(|(from, to)| from.start == to.start && from.height == to.height) + .count() +} + +pub(super) fn sibling_side(is_left_child: bool) -> SiblingSide { + if is_left_child { + SiblingSide::Right + } else { + SiblingSide::Left + } +} + +pub(super) fn balanced_tree_sides(height: usize, mut local_position: usize) -> Vec { + let mut sides = Vec::with_capacity(height); + for _ in 0..height { + sides.push(sibling_side(local_position & 1 == 0)); + local_position >>= 1; + } + sides +} diff --git a/miden-crypto/src/merkle/mmr/belt/tests.rs b/miden-crypto/src/merkle/mmr/belt/tests.rs new file mode 100644 index 0000000000..bedb8a254f --- /dev/null +++ b/miden-crypto/src/merkle/mmr/belt/tests.rs @@ -0,0 +1,1302 @@ +use alloc::{vec, vec::Vec}; +use std::println; + +use super::{ + BeltBaggingState, BeltHashArray, BeltSummary, ChangedMountain, HashIndex, MmrBelt, + MmrBeltDelta, PartialMmrBelt, append_shape_in_place, bag_belt, bag_range, + common_peak_prefix_len, hash_children, leaf_hash_index, node_hash_index, parent_hash_index, + shape_mountain_index, shape_mountain_index_for_num_leaves, shape_mountains, + shape_range_index_for_mountain, shape_range_index_for_num_leaves, shape_ranges, +}; +use crate::{EMPTY_WORD, Word, hash::poseidon2::Poseidon2, merkle::int_to_node}; + +fn shape_heights(num_leaves: usize) -> Vec { + shape_mountains(num_leaves).iter().map(|mountain| mountain.height).collect() +} + +fn shape_hash_indices(num_leaves: usize) -> Vec { + shape_mountains(num_leaves) + .iter() + .map(|mountain| node_hash_index(mountain.start, mountain.height).0) + .collect() +} + +fn hash_range(leaves: &[Word], start: usize, height: usize) -> Word { + if height == 0 { + return leaves[start]; + } + + let half = 1usize << (height - 1); + Poseidon2::merge(&[ + hash_range(leaves, start, height - 1), + hash_range(leaves, start + half, height - 1), + ]) +} + +#[test] +fn belt_hash_indices_match_clojure_layout() { + let leaf_indices = (0..6).map(|position| leaf_hash_index(position).0).collect::>(); + assert_eq!(leaf_indices, vec![3, 5, 7, 9, 11, 13]); + + assert_eq!(parent_hash_index(HashIndex(3)), HashIndex(6)); + assert_eq!(parent_hash_index(HashIndex(5)), HashIndex(6)); + assert_eq!(hash_children(HashIndex(6)), (HashIndex(3), HashIndex(5))); + + assert_eq!(parent_hash_index(HashIndex(6)), HashIndex(12)); + assert_eq!(parent_hash_index(HashIndex(10)), HashIndex(12)); + assert_eq!(hash_children(HashIndex(12)), (HashIndex(6), HashIndex(10))); +} + +#[test] +fn belt_shape_hash_indices_match_peak_layout() { + assert_eq!(shape_hash_indices(1), vec![3]); + assert_eq!(shape_hash_indices(2), vec![6]); + assert_eq!(shape_hash_indices(3), vec![6, 7]); + assert_eq!(shape_hash_indices(4), vec![6, 10]); + assert_eq!(shape_hash_indices(5), vec![12, 11]); + assert_eq!(shape_hash_indices(9), vec![12, 20, 19]); + assert_eq!(shape_hash_indices(10), vec![12, 20, 22]); +} + +#[test] +fn belt_hash_array_preserves_holes_with_compact_value_slots() { + let mut hashes = BeltHashArray::default(); + let value = int_to_node(42); + + hashes.set(HashIndex(5), value); + + assert_eq!(hashes.get(HashIndex(4)), None); + assert_eq!(hashes.get(HashIndex(5)), Some(value)); + assert_eq!(hashes.get(HashIndex(6)), None); + assert_eq!(BeltHashArray::value_slot_bytes_for_testing(), core::mem::size_of::()); +} + +#[test] +fn belt_shape_index_from_leaf_count_matches_shape_scan() { + for num_leaves in 1..4096 { + let shape = shape_mountains(num_leaves); + for &mountain in &shape { + assert_eq!( + shape_mountain_index_for_num_leaves(num_leaves, mountain), + shape_mountain_index(&shape, mountain), + "index mismatch for {mountain:?} at {num_leaves} leaves" + ); + } + } +} + +#[test] +fn belt_range_index_from_leaf_count_matches_range_scan() { + for num_leaves in 1..4096 { + let shape = shape_mountains(num_leaves); + let ranges = shape_ranges(&shape); + + for mountain_idx in 0..shape.len() { + assert_eq!( + shape_range_index_for_num_leaves(num_leaves, mountain_idx), + shape_range_index_for_mountain(&ranges, mountain_idx), + "range index mismatch for mountain {mountain_idx} at {num_leaves} leaves" + ); + } + } +} + +#[test] +fn belt_hash_array_stores_live_mountain_nodes() { + let mut belt = MmrBelt::new(); + let leaves = (0..64).map(int_to_node).collect::>(); + + for leaf in leaves.iter().copied() { + belt.add(leaf).unwrap(); + } + + for mountain in belt.ordered_mountains() { + for height in 0..=mountain.height { + let width = 1usize << height; + for start in (mountain.start..mountain.start + mountain.size()).step_by(width) { + assert_eq!( + belt.hashes.get(node_hash_index(start, height)), + Some(hash_range(&leaves, start, height)), + "missing hash for node [{start}, {})", + start + width + ); + } + } + } +} + +#[test] +fn belt_lazy_append_height_sequence() { + let mut belt = MmrBelt::new(); + let expected = [ + vec![0], + vec![1], + vec![1, 0], + vec![1, 1], + vec![2, 0], + vec![2, 1], + vec![2, 1, 0], + vec![2, 1, 1], + vec![2, 2, 0], + vec![2, 2, 1], + vec![3, 1, 0], + vec![3, 1, 1], + vec![3, 2, 0], + ]; + + for (idx, expected_heights) in expected.into_iter().enumerate() { + belt.add(int_to_node(idx as u64)).unwrap(); + assert_eq!(belt.mountain_heights(), expected_heights); + } +} + +#[test] +fn belt_append_performs_at_most_one_mountain_merge() { + let mut belt = MmrBelt::new(); + + for idx in 0..128 { + let merge_count = belt.add(int_to_node(idx)).unwrap(); + assert!(merge_count <= 1); + } +} + +#[test] +fn belt_append_touches_constant_local_storage() { + let mut belt = MmrBelt::new(); + + for idx in 0..128 { + let before = belt.storage_slots_for_testing(); + belt.add(int_to_node(idx)).unwrap(); + let after = belt.storage_slots_for_testing(); + + assert!(after - before <= 2); + } +} + +#[test] +fn belt_tracks_rightmost_mergeable_pair_without_stale_stack() { + let mut belt = MmrBelt::new(); + + for idx in 0..512 { + belt.add(int_to_node(idx)).unwrap(); + + let mountains = belt.ordered_mountains(); + let expected = mountains + .windows(2) + .rev() + .find(|pair| pair[0].height == pair[1].height) + .map(|pair| (pair[0].start, pair[1].start)); + + assert_eq!(belt.rightmost_mergeable_pair_for_testing(), expected, "after {idx}"); + } +} + +#[test] +fn belt_range_splits_follow_mmb_rules() { + let mut belt = MmrBelt::new(); + let expected = [ + vec![vec![0]], + vec![vec![1]], + vec![vec![1, 0]], + vec![vec![1, 1]], + vec![vec![2], vec![0]], + vec![vec![2, 1]], + vec![vec![2, 1, 0]], + vec![vec![2, 1, 1]], + vec![vec![2, 2], vec![0]], + vec![vec![2, 2], vec![1]], + vec![vec![3], vec![1, 0]], + vec![vec![3], vec![1, 1]], + vec![vec![3, 2], vec![0]], + vec![vec![3, 2, 1]], + vec![vec![3, 2, 1, 0]], + vec![vec![3, 2, 1, 1]], + ]; + + for (idx, expected_ranges) in expected.into_iter().enumerate() { + belt.add(int_to_node(idx as u64)).unwrap(); + assert_eq!(belt.range_heights(), expected_ranges); + } +} + +#[test] +fn belt_summary_root_is_stable_for_same_leaves() { + let mut first = MmrBelt::new(); + let mut second = MmrBelt::new(); + + for idx in 0..32 { + let leaf = int_to_node(idx); + first.add(leaf).unwrap(); + second.add(leaf).unwrap(); + } + + assert_eq!(first.summary().root(), second.summary().root()); + assert_eq!(first.summary().num_leaves(), 32); +} + +#[test] +fn belt_openings_verify_for_all_leaves() { + let mut belt = MmrBelt::new(); + let leaves = (0..37).map(int_to_node).collect::>(); + for leaf in leaves.iter().copied() { + belt.add(leaf).unwrap(); + } + let summary = belt.summary(); + + for (position, leaf) in leaves.into_iter().enumerate() { + let proof = belt.open(position).unwrap(); + assert_eq!(proof.position(), position); + assert_eq!(proof.leaf(), leaf); + assert!(proof.verify(&summary)); + } +} + +#[test] +fn belt_opening_rejects_wrong_leaf() { + let mut belt = MmrBelt::new(); + for idx in 0..16 { + belt.add(int_to_node(idx)).unwrap(); + } + let summary = belt.summary(); + let mut proof = belt.open(7).unwrap(); + + proof.set_leaf_for_testing(int_to_node(999)); + + assert!(!proof.verify(&summary)); +} + +#[test] +fn belt_proof_rejects_tampered_node() { + let mut belt = MmrBelt::new(); + for idx in 0..37 { + belt.add(int_to_node(idx)).unwrap(); + } + let summary = belt.summary(); + + let pristine = belt.open(13).unwrap(); + assert!(pristine.verify(&summary)); + + for node_idx in 0..pristine.node_count_for_testing() { + let mut proof = belt.open(13).unwrap(); + proof.tamper_node_value_for_testing(node_idx, int_to_node(777_000 + node_idx as u64)); + assert!(!proof.verify(&summary), "tampering node {node_idx} should fail verification"); + } +} + +#[test] +fn belt_opening_rejects_wrong_position() { + let mut belt = MmrBelt::new(); + for idx in 0..37 { + belt.add(int_to_node(idx)).unwrap(); + } + let summary = belt.summary(); + let mut proof = belt.open(7).unwrap(); + + proof.set_position_for_testing(5); + + assert!(!proof.verify(&summary)); +} + +#[test] +fn belt_root_binds_shape_without_belt_domain_separation() { + let mut belt = MmrBelt::new(); + for idx in 0..2 { + belt.add(int_to_node(idx)).unwrap(); + } + let summary = belt.summary(); + let peaks = belt.peaks(); + assert_eq!(peaks.len(), 1); + assert_ne!(summary.root(), peaks[0], "root must not be transparent to its peak"); + + let mut bigger = MmrBelt::new(); + for idx in 0..3 { + bigger.add(int_to_node(idx)).unwrap(); + } + assert_ne!(bigger.summary().root(), summary.root()); +} + +#[test] +fn belt_second_bagging_uses_plain_merkle_merge() { + let mut belt = MmrBelt::new(); + for idx in 0..5 { + belt.add(int_to_node(idx)).unwrap(); + } + + let shape = shape_mountains(belt.num_leaves()); + let peaks = belt.peaks(); + let range_roots = shape_ranges(&shape) + .into_iter() + .map(|range| bag_range(&peaks[range])) + .collect::>(); + assert!(range_roots.len() > 1); + + let expected = range_roots.iter().fold(EMPTY_WORD, |acc, &root| Poseidon2::merge(&[acc, root])); + + assert_eq!(belt.summary().root(), expected); +} + +#[test] +fn belt_range_bagging_uses_plain_merkle_merge() { + let mut belt = MmrBelt::new(); + for idx in 0..9 { + belt.add(int_to_node(idx)).unwrap(); + } + + let shape = shape_mountains(belt.num_leaves()); + let peaks = belt.peaks(); + let first_range = shape_ranges(&shape).into_iter().next().unwrap(); + assert!(first_range.len() > 1); + + let expected = peaks[first_range.clone()] + .iter() + .fold(EMPTY_WORD, |acc, &peak| Poseidon2::merge(&[acc, peak])); + + assert_eq!(bag_range(&peaks[first_range]), expected); +} + +#[test] +fn belt_shape_derivation_matches_live_structure_across_pow2() { + let mut belt = MmrBelt::new(); + let leaves = (0..4100u64).map(int_to_node).collect::>(); + for leaf in leaves.iter().copied() { + belt.add(leaf).unwrap(); + } + let summary = belt.summary(); + + for (position, leaf) in leaves.into_iter().enumerate() { + let proof = belt.open(position).unwrap(); + assert_eq!(proof.leaf(), leaf, "leaf mismatch at {position}"); + assert!(proof.verify(&summary), "verify failed at position {position}"); + } +} + +#[test] +fn belt_summary_from_peaks_matches_full_summary() { + let mut belt = MmrBelt::new(); + for idx in 0..100 { + belt.add(int_to_node(idx)).unwrap(); + let summary = belt.summary(); + let from_peaks = BeltSummary::from_peaks(summary.num_leaves(), &belt.peaks()).unwrap(); + assert_eq!(from_peaks, summary); + } +} + +#[test] +fn belt_summary_from_roots_matches_full_summary() { + let mut belt = MmrBelt::new(); + for idx in 0..100 { + belt.add(int_to_node(idx)).unwrap(); + let summary = belt.summary(); + let roots = summary.roots().to_vec(); + let from_roots = BeltSummary::from_roots(summary.num_leaves(), &roots).unwrap(); + assert_eq!(from_roots, summary); + } +} + +#[test] +fn belt_summary_exposes_mountain_order_roots() { + let mut belt = MmrBelt::new(); + for idx in 0..37 { + belt.add(int_to_node(idx)).unwrap(); + } + + let summary = belt.summary(); + let peaks = belt.peaks(); + + assert_eq!(summary.roots(), peaks.as_slice()); + assert_eq!(summary.commitment_root(), summary.root()); + assert_eq!( + summary.commitment_root(), + BeltSummary::from_roots(summary.num_leaves(), summary.roots()).unwrap().root() + ); +} + +#[test] +fn belt_summary_uses_maintained_mountain_order_roots() { + let mut belt = MmrBelt::new(); + for idx in 0..37 { + belt.add(int_to_node(idx)).unwrap(); + } + + let summary = belt.summary(); + let roots = belt.peaks(); + + belt.hashes.clear(); + + assert_eq!(belt.peaks(), roots); + assert_eq!(belt.summary(), summary); +} + +#[test] +fn belt_summary_carries_explicit_range_bagging_state() { + let mut belt = MmrBelt::new(); + for idx in 0..190 { + belt.add(int_to_node(idx)).unwrap(); + } + + let summary = belt.summary(); + let shape = shape_mountains(summary.num_leaves()); + let expected_range_roots = shape_ranges(&shape) + .into_iter() + .map(|range| bag_range(&summary.roots()[range])) + .collect::>(); + + assert!(expected_range_roots.len() > 1); + assert_eq!(summary.range_roots_for_testing(), expected_range_roots.as_slice()); + assert_eq!(summary.commitment_root(), bag_belt(&expected_range_roots)); +} + +#[test] +fn belt_bagging_state_uses_explicit_range_and_belt_nodes() { + let mut belt = MmrBelt::new(); + for idx in 0..190 { + belt.add(int_to_node(idx)).unwrap(); + } + + for (nodes, &range_root) in belt.bagging.range_nodes().iter().zip(belt.bagging.range_roots()) { + let mut prefix = EMPTY_WORD; + for node in nodes { + assert_eq!(node.left, prefix); + assert_eq!(node.root, Poseidon2::merge(&[node.left, node.right])); + prefix = node.root; + } + assert_eq!(prefix, range_root); + } + + let mut prefix = EMPTY_WORD; + for (node, &range_root) in belt.bagging.belt_nodes().iter().zip(belt.bagging.range_roots()) { + assert_eq!(node.left, prefix); + assert_eq!(node.right, range_root); + assert_eq!(node.root, Poseidon2::merge(&[node.left, node.right])); + prefix = node.root; + } + assert_eq!(prefix, belt.summary().commitment_root()); +} + +#[test] +fn changed_range_nodes_rejects_extra_changed_mountain_in_range_prefix() { + let old_num_leaves = 190; + let new_num_leaves = old_num_leaves + 1; + let roots = vec![EMPTY_WORD; shape_mountains(old_num_leaves).len()]; + let mut state = BeltBaggingState::from_roots(old_num_leaves, &roots).unwrap(); + + let new_shape = shape_mountains(new_num_leaves); + let range = shape_ranges(&new_shape) + .into_iter() + .find(|range| range.end - range.start > 1) + .unwrap(); + let prefix = new_shape[range.start]; + let last = new_shape[range.end - 1]; + let old_idx = + shape_mountain_index_for_num_leaves(old_num_leaves, prefix).expect("prefix is reused"); + let old_range_idx = shape_range_index_for_num_leaves(old_num_leaves, old_idx) + .expect("reused prefix has an old range"); + let changed = [ + ChangedMountain::new(last.start, last.height, int_to_node(1)), + ChangedMountain::new(prefix.start, prefix.height, int_to_node(2)), + ]; + + assert!( + state + .changed_range_nodes( + old_num_leaves, + new_num_leaves, + range, + &changed, + old_range_idx, + state.ranges.len(), + ) + .is_err() + ); +} + +#[test] +fn append_shape_in_place_rejects_extra_unchanged_changed_mountain() { + let old_num_leaves = 3; + let mut shape = shape_mountains(old_num_leaves); + let unchanged = shape[0]; + let leaf = ChangedMountain::new(old_num_leaves, 0, int_to_node(old_num_leaves as u64)); + let extra = ChangedMountain { + mountain: unchanged, + root: int_to_node(99), + }; + + assert!(append_shape_in_place(&mut shape, old_num_leaves, &[leaf, extra]).is_err()); +} + +#[test] +fn belt_maintains_live_bagging_state_after_each_append() { + let mut belt = MmrBelt::new(); + + for idx in 0..512 { + belt.add(int_to_node(idx)).unwrap(); + + let rebuilt = BeltSummary::from_roots(belt.num_leaves(), &belt.peaks()).unwrap(); + + assert_eq!(belt.live_range_roots_for_testing(), rebuilt.range_roots_for_testing()); + assert_eq!(belt.live_commitment_root_for_testing(), rebuilt.commitment_root()); + assert_eq!(belt.summary(), rebuilt); + } +} + +#[test] +fn belt_lazy_bagging_append_keeps_mountain_state() { + let mut normal = MmrBelt::new(); + let mut lazy = MmrBelt::new(); + + for idx in 0..512 { + let leaf = int_to_node(idx); + normal.add(leaf).unwrap(); + lazy.add_without_bagging_for_benchmark(leaf).unwrap(); + + assert_eq!(lazy.num_leaves(), normal.num_leaves()); + assert_eq!(lazy.peaks(), normal.peaks()); + + let rebuilt = BeltSummary::from_roots(lazy.num_leaves(), &lazy.peaks()).unwrap(); + assert_eq!(rebuilt, normal.summary()); + } +} + +#[test] +fn belt_lazy_bagging_append_does_not_touch_live_bagging() { + let mut belt = MmrBelt::new(); + + for idx in 0..512 { + belt.add_without_bagging_for_benchmark(int_to_node(idx)).unwrap(); + + assert_eq!(belt.last_bagging_update_hashes_for_testing(), 0); + } +} + +#[test] +fn belt_commitment_root_returns_live_bagged_root() { + let mut belt = MmrBelt::new(); + + for idx in 0..512 { + belt.add(int_to_node(idx)).unwrap(); + + let rebuilt = BeltSummary::from_roots(belt.num_leaves(), &belt.peaks()).unwrap(); + + assert_eq!(belt.commitment_root(), rebuilt.commitment_root()); + } +} + +#[test] +fn belt_live_bagging_update_touches_constant_commitment_hashes() { + const MAX_INCREMENTAL_BAGGING_HASHES: usize = 4; + + let mut belt = MmrBelt::new(); + for idx in 0..4096 { + belt.add(int_to_node(idx)).unwrap(); + + assert!( + belt.last_bagging_update_hashes_for_testing() <= MAX_INCREMENTAL_BAGGING_HASHES, + "append {idx} touched {} commitment-layer hashes", + belt.last_bagging_update_hashes_for_testing() + ); + } +} + +#[test] +fn belt_delta_resyncs_client_summary() { + let total = 600usize; + for from in [0usize, 1, 7, 64, 255, 256, 511] { + let mut belt = MmrBelt::new(); + for idx in 0..from { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let client_peaks = belt.peaks(); + + for idx in from..total { + belt.add(int_to_node(idx as u64)).unwrap(); + } + + let delta = belt.delta(from).unwrap(); + assert_eq!(delta.from_num_leaves(), from); + assert_eq!(delta.to_num_leaves(), total); + + let updated = delta.apply(&client_peaks).unwrap(); + assert_eq!(updated, belt.peaks(), "resynced peaks must match (from {from})"); + assert_eq!( + BeltSummary::from_roots(total, &updated).unwrap(), + belt.summary(), + "resynced commitment must match (from {from})" + ); + } +} + +#[test] +fn belt_delta_verifies_summary_transition() { + let from = 128usize; + let to = 191usize; + let mut belt = MmrBelt::new(); + for idx in 0..from { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let old_summary = belt.summary(); + + for idx in from..to { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let new_summary = belt.summary(); + let delta = belt.delta(from).unwrap(); + + assert!(delta.verify_transition(&old_summary, &new_summary).unwrap()); +} + +#[test] +fn belt_delta_rejects_wrong_transition_endpoint() { + let from = 64usize; + let to = 96usize; + let mut belt = MmrBelt::new(); + for idx in 0..from { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let old_summary = belt.summary(); + + for idx in from..to { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let delta = belt.delta(from).unwrap(); + + let mut other = MmrBelt::new(); + for idx in 0..to { + other.add(int_to_node(10_000 + idx as u64)).unwrap(); + } + let wrong_new_summary = other.summary(); + + assert!(!delta.verify_transition(&old_summary, &wrong_new_summary).unwrap()); +} + +#[test] +fn belt_delta_rejects_mutated_tail_peak() { + let from = 128usize; + let to = 191usize; + let mut belt = MmrBelt::new(); + for idx in 0..from { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let old_summary = belt.summary(); + + for idx in from..to { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let new_summary = belt.summary(); + let mut delta = belt.delta(from).unwrap(); + delta.new_tail_peaks[0] = int_to_node(999_999); + + assert!(!delta.verify_transition(&old_summary, &new_summary).unwrap()); +} + +#[test] +fn belt_delta_rejects_wrong_absorbed_old_summary_root() { + let from = 128usize; + let to = 191usize; + let mut belt = MmrBelt::new(); + for idx in 0..from { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let old_summary = belt.summary(); + let common = common_peak_prefix_len(from, to); + assert!(common < old_summary.roots().len()); + + let mut wrong_old_roots = old_summary.roots().to_vec(); + wrong_old_roots[common] = int_to_node(999_999); + let wrong_old_summary = BeltSummary::from_roots(from, &wrong_old_roots).unwrap(); + + for idx in from..to { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let new_summary = belt.summary(); + let delta = belt.delta(from).unwrap(); + + assert!(!delta.verify_transition(&wrong_old_summary, &new_summary).unwrap()); +} + +#[test] +fn belt_delta_transition_requires_merge_auth_for_absorbed_old_roots() { + let from = 200usize; + let to = 260usize; + let mut belt = MmrBelt::new(); + for idx in 0..from { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let old_summary = belt.summary(); + + for idx in from..to { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let new_summary = belt.summary(); + let mut delta = belt.delta(from).unwrap(); + assert!(common_peak_prefix_len(from, to) < old_summary.roots().len()); + delta.merge_auth.clear(); + + assert!(delta.verify_transition(&old_summary, &new_summary).is_err()); +} + +#[test] +fn belt_delta_rejects_forged_merge_auth_value() { + let from = 200usize; + let to = 260usize; + let mut belt = MmrBelt::new(); + for idx in 0..from { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let old_summary = belt.summary(); + + for idx in from..to { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let new_summary = belt.summary(); + + let mut delta = belt.delta(from).unwrap(); + assert!(delta.verify_transition(&old_summary, &new_summary).unwrap()); + + let key = *delta.merge_auth.keys().next().expect("absorbed roots need merge-auth nodes"); + delta.merge_auth.insert(key, int_to_node(999_999)); + + assert!(!delta.verify_transition(&old_summary, &new_summary).unwrap()); +} + +#[test] +fn belt_zero_and_single_leaf_summaries() { + let empty = MmrBelt::new(); + let empty_summary = empty.summary(); + assert_eq!(empty_summary.num_leaves(), 0); + assert!(empty_summary.roots().is_empty()); + assert_eq!(empty_summary.commitment_root(), EMPTY_WORD); + assert_eq!( + empty_summary, + BeltSummary::from_roots(0, &[]).unwrap(), + "live empty summary must match a from-scratch rebuild" + ); + + let mut single = MmrBelt::new(); + single.add(int_to_node(42)).unwrap(); + let single_summary = single.summary(); + assert_eq!(single_summary.num_leaves(), 1); + assert_eq!(single_summary, BeltSummary::from_roots(1, &single.peaks()).unwrap()); + + let proof = single.open(0).unwrap(); + assert!(proof.verify(&single_summary)); +} + +#[test] +fn belt_delta_from_same_leaf_count_is_noop() { + let mut belt = MmrBelt::new(); + for idx in 0..50 { + belt.add(int_to_node(idx)).unwrap(); + } + let summary = belt.summary(); + + let delta = belt.delta(belt.num_leaves()).unwrap(); + assert!(delta.verify_transition(&summary, &summary).unwrap()); + assert_eq!(delta.apply(summary.roots()).unwrap(), summary.roots()); + + let empty = MmrBelt::new(); + let empty_summary = empty.summary(); + let empty_delta = empty.delta(0).unwrap(); + assert!(empty_delta.verify_transition(&empty_summary, &empty_summary).unwrap()); + assert!(empty_delta.apply(empty_summary.roots()).unwrap().is_empty()); +} + +#[test] +fn belt_delta_is_logarithmic_in_increment() { + let mut belt = MmrBelt::new(); + for idx in 0..100_000u64 { + belt.add(int_to_node(idx)).unwrap(); + } + + for k in [1usize, 2, 10, 100, 1000] { + let delta = belt.delta(100_000 - k).unwrap(); + let bound = 2 * (usize::BITS - k.leading_zeros()) as usize + 4; + assert!( + delta.new_tail_peaks().len() <= bound, + "k={k}: tail {} exceeded bound {bound}", + delta.new_tail_peaks().len() + ); + } +} + +#[test] +fn belt_delta_rejects_future_origin() { + let mut belt = MmrBelt::new(); + for idx in 0..10 { + belt.add(int_to_node(idx)).unwrap(); + } + assert!(belt.delta(11).is_err()); +} + +#[test] +fn partial_belt_tracks_and_opens_like_full_belt() { + let mut belt = MmrBelt::new(); + let leaves = (0..50u64).map(int_to_node).collect::>(); + for leaf in leaves.iter().copied() { + belt.add(leaf).unwrap(); + } + + let mut partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); + assert_eq!(partial.summary(), belt.summary()); + + for position in 0..leaves.len() { + partial.track(&belt.open(position).unwrap()).unwrap(); + } + assert_eq!(partial.num_tracked(), leaves.len()); + + for (position, leaf) in leaves.iter().copied().enumerate() { + let proof = partial.open(position).unwrap().unwrap(); + assert_eq!(proof, belt.open(position).unwrap()); + assert_eq!(partial.get(position), Some(leaf)); + assert!(proof.verify(&partial.summary())); + } +} + +#[test] +fn partial_belt_from_peaks_rejects_wrong_count() { + let mut belt = MmrBelt::new(); + for idx in 0..7 { + belt.add(int_to_node(idx)).unwrap(); + } + let mut peaks = belt.peaks(); + peaks.pop(); + assert!(PartialMmrBelt::from_peaks(belt.num_leaves(), peaks).is_err()); +} + +#[test] +fn partial_belt_track_rejects_unauthenticated_proof() { + let mut belt = MmrBelt::new(); + for idx in 0..16 { + belt.add(int_to_node(idx)).unwrap(); + } + let mut partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); + + let mut proof = belt.open(5).unwrap(); + proof.set_leaf_for_testing(int_to_node(999)); + assert!(partial.track(&proof).is_err()); + assert!(!partial.is_tracked(5)); +} + +#[test] +fn partial_belt_apply_extends_all_tracks_in_place() { + let from = 200usize; + let to = 260usize; + + let mut belt = MmrBelt::new(); + for idx in 0..from { + belt.add(int_to_node(idx as u64)).unwrap(); + } + + let mut partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); + for position in 0..from { + partial.track(&belt.open(position).unwrap()).unwrap(); + } + + for idx in from..to { + belt.add(int_to_node(idx as u64)).unwrap(); + } + + partial.apply(&belt.delta(from).unwrap()).unwrap(); + + assert_eq!(partial.num_leaves(), to); + assert_eq!(partial.summary(), belt.summary()); + assert_eq!(partial.num_tracked(), from); + + for position in 0..from { + assert!(partial.is_tracked(position), "leaf {position} must still be tracked"); + assert_eq!(partial.open(position).unwrap().unwrap(), belt.open(position).unwrap()); + } +} + +#[test] +fn partial_belt_apply_extends_across_many_increments() { + let mut belt = MmrBelt::new(); + for idx in 0..40u64 { + belt.add(int_to_node(idx)).unwrap(); + } + + let mut partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); + let tracked = [0usize, 1, 17, 38, 39]; + for &position in &tracked { + partial.track(&belt.open(position).unwrap()).unwrap(); + } + + let mut next = 40u64; + for _ in 0..30 { + let from = belt.num_leaves(); + for _ in 0..7 { + belt.add(int_to_node(next)).unwrap(); + next += 1; + } + partial.apply(&belt.delta(from).unwrap()).unwrap(); + + assert_eq!(partial.summary(), belt.summary()); + for &position in &tracked { + assert_eq!(partial.open(position).unwrap().unwrap(), belt.open(position).unwrap()); + } + } +} + +#[test] +fn partial_belt_protocol_model_resyncs_after_offline_increment() { + let from = 128usize; + let to = 191usize; + let leaves = (0..to as u64).map(int_to_node).collect::>(); + + let mut full_node = MmrBelt::new(); + for &leaf in &leaves[..from] { + full_node.add(leaf).unwrap(); + } + + let mut client = PartialMmrBelt::from_peaks(full_node.num_leaves(), full_node.peaks()).unwrap(); + for &position in &[0usize, 1, 7, 63, 64, 100, 127] { + client.track(&full_node.open(position).unwrap()).unwrap(); + } + assert_eq!(client.summary(), full_node.summary()); + + for &leaf in &leaves[from..to] { + full_node.add(leaf).unwrap(); + } + let server_delta = full_node.delta(from).unwrap(); + + assert_ne!(client.summary(), full_node.summary()); + client.apply_verified(&server_delta, &full_node.summary()).unwrap(); + assert_eq!(client.summary(), full_node.summary()); + + for &position in &[0usize, 1, 7, 63, 64, 100, 127] { + let client_proof = client.open(position).unwrap().unwrap(); + assert!(client_proof.verify(&client.summary())); + assert_eq!(client_proof, full_node.open(position).unwrap()); + assert_eq!(client.get(position), Some(leaves[position])); + } + + let newest_position = to - 1; + client.track(&full_node.open(newest_position).unwrap()).unwrap(); + assert_eq!(client.get(newest_position), Some(leaves[newest_position])); + assert!(client.open(newest_position).unwrap().unwrap().verify(&client.summary())); +} + +#[test] +fn belt_delta_and_summary_verify_sync_response_transition() { + let from = 128usize; + let to = 191usize; + let mut belt = MmrBelt::new(); + for idx in 0..from { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let old_summary = belt.summary(); + + for idx in from..to { + belt.add(int_to_node(idx as u64)).unwrap(); + } + + let belt_delta = belt.delta(from).unwrap(); + let belt_summary = belt.summary(); + assert_eq!(belt_delta.from_num_leaves(), from); + assert_eq!(belt_delta.to_num_leaves(), to); + assert!(belt_delta.verify_transition(&old_summary, &belt_summary).unwrap()); +} + +#[test] +fn partial_belt_apply_verified_resyncs_sync_response() { + let from = 128usize; + let to = 191usize; + let leaves = (0..to as u64).map(int_to_node).collect::>(); + + let mut full_node = MmrBelt::new(); + for &leaf in &leaves[..from] { + full_node.add(leaf).unwrap(); + } + let mut client = PartialMmrBelt::from_peaks(full_node.num_leaves(), full_node.peaks()).unwrap(); + client.track(&full_node.open(from - 1).unwrap()).unwrap(); + + for &leaf in &leaves[from..to] { + full_node.add(leaf).unwrap(); + } + let belt_delta = full_node.delta(from).unwrap(); + let belt_summary = full_node.summary(); + + client.apply_verified(&belt_delta, &belt_summary).unwrap(); + assert_eq!(client.summary(), full_node.summary()); + assert_eq!(client.open(from - 1).unwrap().unwrap(), full_node.open(from - 1).unwrap()); +} + +#[test] +fn belt_delta_from_parts_reconstructs_sync_response() { + let from = 128usize; + let to = 191usize; + let leaves = (0..to as u64).map(int_to_node).collect::>(); + + let mut full_node = MmrBelt::new(); + for &leaf in &leaves[..from] { + full_node.add(leaf).unwrap(); + } + let old_summary = full_node.summary(); + let mut client = PartialMmrBelt::from_peaks(full_node.num_leaves(), full_node.peaks()).unwrap(); + client.track(&full_node.open(from - 1).unwrap()).unwrap(); + + for &leaf in &leaves[from..to] { + full_node.add(leaf).unwrap(); + } + + let server_delta = full_node.delta(from).unwrap(); + let server_summary = full_node.summary(); + let belt_delta = MmrBeltDelta::from_parts( + server_delta.from_num_leaves(), + server_delta.to_num_leaves(), + server_delta.new_tail_peaks().to_vec(), + server_delta.merge_auth_nodes().collect::>(), + ) + .unwrap(); + let summary_roots = server_summary.roots().to_vec(); + let belt_summary = + BeltSummary::from_roots(server_summary.num_leaves(), &summary_roots).unwrap(); + + assert_eq!(belt_delta, server_delta); + assert_eq!(belt_summary, server_summary); + assert!(belt_delta.verify_transition(&old_summary, &belt_summary).unwrap()); + + client.apply_verified(&belt_delta, &belt_summary).unwrap(); + assert_eq!(client.summary(), full_node.summary()); + assert_eq!(client.open(from - 1).unwrap().unwrap(), full_node.open(from - 1).unwrap()); +} + +#[test] +fn belt_delta_from_parts_rejects_invalid_tail_shape() { + let mut belt = MmrBelt::new(); + for idx in 0..191 { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let server_delta = belt.delta(128).unwrap(); + + assert!( + MmrBeltDelta::from_parts( + server_delta.from_num_leaves(), + server_delta.to_num_leaves(), + vec![], + server_delta.merge_auth_nodes().collect::>() + ) + .is_err() + ); +} + +#[test] +fn partial_belt_apply_verified_rejects_wrong_new_summary() { + let from = 64usize; + let to = 96usize; + let mut belt = MmrBelt::new(); + for idx in 0..from { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let mut client = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); + client.track(&belt.open(from - 1).unwrap()).unwrap(); + let old_summary = client.summary(); + + for idx in from..to { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let delta = belt.delta(from).unwrap(); + + let mut other = MmrBelt::new(); + for idx in 0..to { + other.add(int_to_node(10_000 + idx as u64)).unwrap(); + } + let wrong_new_summary = other.summary(); + + assert!(client.apply_verified(&delta, &wrong_new_summary).is_err()); + assert_eq!(client.summary(), old_summary); +} + +#[test] +fn partial_belt_apply_verified_rejects_missing_merge_auth_without_mutating() { + let from = 200usize; + let to = 260usize; + let mut belt = MmrBelt::new(); + for idx in 0..from { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let mut client = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); + client.track(&belt.open(from - 1).unwrap()).unwrap(); + let old_summary = client.summary(); + + for idx in from..to { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let new_summary = belt.summary(); + let mut delta = belt.delta(from).unwrap(); + delta.merge_auth.clear(); + + assert!(client.apply_verified(&delta, &new_summary).is_err()); + assert_eq!(client.summary(), old_summary); +} + +#[test] +fn partial_belt_delta_merge_auth_is_polylogarithmic() { + let mut belt = MmrBelt::new(); + for idx in 0..100_000u64 { + belt.add(int_to_node(idx)).unwrap(); + } + + for k in [1usize, 2, 16, 256, 4096] { + let delta = belt.delta(100_000 - k).unwrap(); + let log_k = (usize::BITS - k.leading_zeros()) as usize; + let bound = 4 * log_k * log_k + 8; + assert!( + delta.num_merge_auth_nodes() <= bound, + "k={k}: {} auth nodes exceeded bound {bound}", + delta.num_merge_auth_nodes() + ); + } +} + +#[test] +#[ignore = "prints deterministic MMB issue measurement tables"] +fn mmb_issue_measurement_report() { + const STORAGE_SIZES: &[usize] = &[1_000, 65_536, 100_000]; + const PROOF_SIZE: usize = 65_536; + const DELTA_SIZE: usize = 100_000; + const RECENCIES: &[usize] = &[1, 2, 4, 8, 16, 64, 512, 4_096, 16_384, 65_536]; + const GAPS: &[usize] = &[1, 2, 4, 8, 16, 32, 64, 128, 512, 4_096, 16_384, 65_536]; + + println!("# MMB issue measurement report"); + println!(); + println!("## Storage shape"); + println!( + "| leaves | MMR nodes | MMR peaks | Frontier peaks | Belt hash slots | Belt live hashes | Belt mountains | Belt ranges | Belt range nodes | Belt belt nodes |" + ); + println!("|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|"); + for &size in STORAGE_SIZES { + let (mmr, belt) = build_mmr_and_belt(size); + let frontier = mmr.frontier(); + let range_nodes = belt.bagging.range_nodes().iter().map(Vec::len).sum::(); + println!( + "| {size} | {} | {} | {} | {} | {} | {} | {} | {} | {} |", + mmr.forest().num_nodes(), + mmr.peaks().num_peaks(), + frontier.num_peaks(), + belt.hashes.slot_count(), + belt.hashes.live_count(), + belt.ordered_mountains().len(), + belt.bagging.range_roots().len(), + range_nodes, + belt.bagging.belt_nodes().len() + ); + } + + let (mmr, belt) = build_mmr_and_belt(PROOF_SIZE); + let summary = belt.summary(); + println!(); + println!("## Proof nodes by recency"); + println!("n = {PROOF_SIZE}"); + println!("| recency k | position | MMR path nodes | Frontier proof nodes | Belt proof nodes |"); + println!("|---:|---:|---:|---:|---:|"); + for &recency in RECENCIES { + if recency > PROOF_SIZE { + continue; + } + let position = PROOF_SIZE - recency; + let mmr_nodes = mmr.open(position).unwrap().merkle_path().nodes().len(); + let frontier_proof_nodes = mmr.open_frontier(position).unwrap().path.nodes().len(); + let belt_proof = belt.open(position).unwrap(); + assert!(belt_proof.verify(&summary)); + println!( + "| {recency} | {position} | {mmr_nodes} | {frontier_proof_nodes} | {} |", + belt_proof.nodes.len() + ); + } + + let (mmr, belt) = build_mmr_and_belt(DELTA_SIZE); + println!(); + println!("## Delta words by offline gap"); + println!("target n = {DELTA_SIZE}"); + println!( + "| gap k | from leaves | Current MMR delta words | Belt tail roots | Belt merge auth nodes | Belt total words |" + ); + println!("|---:|---:|---:|---:|---:|---:|"); + for &gap in GAPS { + if gap > DELTA_SIZE { + continue; + } + let from = DELTA_SIZE - gap; + let mmr_delta = mmr + .get_delta(crate::merkle::mmr::Forest::new(from).unwrap(), mmr.forest()) + .unwrap(); + let belt_delta = belt.delta(from).unwrap(); + let tail_roots = belt_delta.new_tail_peaks().len(); + let merge_auth = belt_delta.num_merge_auth_nodes(); + println!( + "| {gap} | {from} | {} | {tail_roots} | {merge_auth} | {} |", + mmr_delta.data.len(), + tail_roots + merge_auth + ); + } +} + +fn build_mmr_and_belt(size: usize) -> (crate::merkle::mmr::Mmr, MmrBelt) { + let leaves = (0..size as u64).map(int_to_node).collect::>(); + let mmr = crate::merkle::mmr::Mmr::try_from_iter(leaves.iter().copied()).unwrap(); + let mut belt = MmrBelt::new(); + for leaf in leaves { + belt.add(leaf).unwrap(); + } + (mmr, belt) +} + +#[test] +fn partial_belt_open_untracked_returns_none() { + let mut belt = MmrBelt::new(); + for idx in 0..16 { + belt.add(int_to_node(idx)).unwrap(); + } + let partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); + assert!(partial.open(3).unwrap().is_none()); +} + +#[test] +fn belt_height_sequences_match_paper() { + // Golden S_n sequences from arXiv:2511.13582, §3.1 and Figures 5/7/9. + let golden: [(usize, &[usize]); 4] = [ + (9, &[2, 2, 0]), + (10, &[2, 2, 1]), + (11, &[3, 1, 0]), + (1337, &[9, 9, 7, 6, 6, 5, 4, 2, 2, 0]), + ]; + + for (num_leaves, expected) in golden { + assert_eq!(shape_heights(num_leaves), expected, "shape S_{num_leaves}"); + + let mut belt = MmrBelt::new(); + for idx in 0..num_leaves { + belt.add(int_to_node(idx as u64)).unwrap(); + } + assert_eq!(belt.mountain_heights(), expected, "live S_{num_leaves}"); + } +} + +#[test] +fn belt_merge_peak_lands_in_last_two_ranges() { + // Lemma 16. + for num_leaves in 2..4096usize { + if (num_leaves + 1).is_power_of_two() { + continue; + } + + let shape = shape_mountains(num_leaves); + let merge_idx = shape.len() - 1 - (num_leaves + 1).trailing_zeros() as usize; + + let ranges = shape_ranges(&shape); + let range_idx = ranges + .iter() + .position(|range| range.contains(&merge_idx)) + .expect("merge peak must lie in a range"); + + assert_eq!( + ranges[range_idx].end, + merge_idx + 1, + "n={num_leaves}: merge peak must sit at the right end of its range" + ); + assert!( + range_idx + 2 >= ranges.len(), + "n={num_leaves}: merge peak must be in the rightmost or second-rightmost range" + ); + } +} From d6db0781ff92eadff2642cd9193f5b7739c1bd38 Mon Sep 17 00:00:00 2001 From: krushimir <189111540+krushimir@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:33:06 +0200 Subject: [PATCH 06/10] fix(mmr): harden and tidy the Merkle Mountain Belt prototype --- miden-crypto/src/merkle/mmr/belt/bagging.rs | 34 ++-------- miden-crypto/src/merkle/mmr/belt/delta.rs | 62 +++++++++++------ miden-crypto/src/merkle/mmr/belt/mod.rs | 12 +--- miden-crypto/src/merkle/mmr/belt/proof.rs | 15 ++--- miden-crypto/src/merkle/mmr/belt/shape.rs | 63 ++++++++++------- miden-crypto/src/merkle/mmr/belt/tests.rs | 75 ++++++++++++++++++++- 6 files changed, 164 insertions(+), 97 deletions(-) diff --git a/miden-crypto/src/merkle/mmr/belt/bagging.rs b/miden-crypto/src/merkle/mmr/belt/bagging.rs index ad8a2b2be4..dcf19f0541 100644 --- a/miden-crypto/src/merkle/mmr/belt/bagging.rs +++ b/miden-crypto/src/merkle/mmr/belt/bagging.rs @@ -1,8 +1,7 @@ use alloc::{format, vec::Vec}; use core::ops::Range; -use super::shape::*; -use super::{BeltMountain, MmrError}; +use super::{BeltMountain, MmrError, shape::*}; use crate::{EMPTY_WORD, Word, hash::poseidon2::Poseidon2}; struct AppendChangedRanges { @@ -31,7 +30,7 @@ impl AppendChangedRanges { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub(super) struct BeltBaggingState { num_leaves: usize, range_len: usize, @@ -117,7 +116,7 @@ impl BeltBaggingState { *self = Self { num_leaves: new_num_leaves, range_len: 1, - ranges: Vec::from([0..1]), + ranges: core::iter::once(0..1).collect(), range_roots: Vec::from([range_node.root]), range_nodes: Vec::from([Vec::from([range_node])]), belt_nodes: Vec::from([belt_node]), @@ -151,7 +150,6 @@ impl BeltBaggingState { range_idx, range.clone(), changed_root, - range_idx, old_ranges_len, ); hashes += range_hashes; @@ -186,7 +184,6 @@ impl BeltBaggingState { range_idx: usize, new_range: Range, changed_root: Word, - first_old_range_idx: usize, old_ranges_len: usize, ) -> usize { debug_assert!(new_range.start < new_range.end); @@ -207,7 +204,7 @@ impl BeltBaggingState { .expect("new range prefix must reuse an old mountain"); let old_range_idx = shape_range_index_for_num_leaves(old_num_leaves, old_idx) .expect("reused mountain must belong to an old range"); - debug_assert!((first_old_range_idx..old_ranges_len).contains(&old_range_idx)); + debug_assert!((range_idx..old_ranges_len).contains(&old_range_idx)); let old_range = self.ranges[old_range_idx].clone(); debug_assert_eq!(old_idx, old_range.start); debug_assert!(old_range.start + changed <= old_range.end); @@ -353,16 +350,6 @@ impl BeltBaggingState { &self.belt_nodes[..self.range_len] } - #[cfg(test)] - pub(super) fn storage_lengths(&self) -> [usize; 4] { - [ - self.ranges.len(), - self.range_roots.len(), - self.range_nodes.len(), - self.belt_nodes.len(), - ] - } - pub(super) fn root(&self) -> Word { if self.range_len == 0 { EMPTY_WORD @@ -385,19 +372,6 @@ impl PartialEq for BeltBaggingState { impl Eq for BeltBaggingState {} -impl Default for BeltBaggingState { - fn default() -> Self { - Self { - num_leaves: 0, - range_len: 0, - ranges: Vec::new(), - range_roots: Vec::new(), - range_nodes: Vec::new(), - belt_nodes: Vec::new(), - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) struct RangeNode { pub(super) left: Word, diff --git a/miden-crypto/src/merkle/mmr/belt/delta.rs b/miden-crypto/src/merkle/mmr/belt/delta.rs index fad5e29f59..3e304f14a6 100644 --- a/miden-crypto/src/merkle/mmr/belt/delta.rs +++ b/miden-crypto/src/merkle/mmr/belt/delta.rs @@ -1,9 +1,10 @@ -use alloc::{collections::BTreeMap, vec::Vec}; +use alloc::{ + collections::{BTreeMap, BTreeSet}, + vec::Vec, +}; -use super::proof::*; -use super::shape::*; -use super::{BeltProof, BeltSummary, MmrError}; -use crate::{Word, hash::poseidon2::Poseidon2}; +use super::{BeltProof, BeltSummary, MmrError, proof::*, shape::*}; +use crate::Word; #[derive(Debug, Clone, PartialEq, Eq)] pub struct MmrBeltDelta { @@ -26,6 +27,8 @@ impl MmrBeltDelta { if from_num_leaves > to_num_leaves { return Err(MmrError::ForestOutOfBounds(from_num_leaves, to_num_leaves)); } + // `to_num_leaves` bounds `from_num_leaves`, so validating it guards both shape derivations. + validate_num_leaves(to_num_leaves)?; let common = common_peak_prefix_len(from_num_leaves, to_num_leaves); let new_len = shape_len_for_num_leaves(to_num_leaves); @@ -106,6 +109,7 @@ impl MmrBeltDelta { new_shape: &[ShapeMountain], common: usize, ) -> Result { + let mut required = BTreeSet::new(); for (old_mountain, &old_root) in old_shape.iter().zip(old_summary.roots()).skip(common) { let new_idx = shape_mountain_for_position(new_shape, old_mountain.start) .ok_or(MmrError::InvalidUpdate)?; @@ -124,12 +128,10 @@ impl MmrBeltDelta { for (side, sibling_start, height) in climb_to_peak(old_mountain.start, old_mountain.height, new_mountain.height) { + required.insert((sibling_start, height)); let &sibling = self.merge_auth.get(&(sibling_start, height)).ok_or(MmrError::InvalidUpdate)?; - root = match side { - SiblingSide::Left => Poseidon2::merge(&[sibling, root]), - SiblingSide::Right => Poseidon2::merge(&[root, sibling]), - }; + root = merge_with_side(side, root, sibling); } if root != new_summary.roots()[new_idx] { @@ -137,6 +139,12 @@ impl MmrBeltDelta { } } + // Every required key is present (the climb would have errored otherwise), so an equal count + // rejects deltas padded with auth nodes outside the required set. + if self.merge_auth.len() != required.len() { + return Ok(false); + } + Ok(true) } @@ -184,6 +192,7 @@ struct TrackedLeaf { impl PartialMmrBelt { pub fn from_peaks(num_leaves: usize, peaks: Vec) -> Result { + validate_num_leaves(num_leaves)?; let expected = shape_len_for_num_leaves(num_leaves); if peaks.len() != expected { return Err(MmrError::InvalidPeaks(format!( @@ -275,11 +284,8 @@ impl PartialMmrBelt { return Err(MmrError::InvalidUpdate); } - let mut updated = self.clone(); - updated.apply(delta)?; - *self = updated; - - Ok(()) + // `apply` is transactional, so a failure here cannot leave the view partially advanced. + self.apply(delta) } pub fn apply(&mut self, delta: &MmrBeltDelta) -> Result<(), MmrError> { @@ -287,15 +293,18 @@ impl PartialMmrBelt { return Err(MmrError::InvalidUpdate); } - self.peaks = delta.apply(&self.peaks)?; - self.num_leaves = delta.to_num_leaves(); + // Build the new state in locals and commit only after every fallible step succeeds. + let new_peaks = delta.apply(&self.peaks)?; + let new_num_leaves = delta.to_num_leaves(); + let new_shape = shape_mountains(new_num_leaves); - let new_shape = shape_mountains(self.num_leaves); - for (&pos, tracked) in self.tracked.iter_mut() { + let mut new_tracked = BTreeMap::new(); + for (&pos, tracked) in &self.tracked { let mountain_idx = shape_mountain_for_position(&new_shape, pos) .ok_or(MmrError::PositionNotFound(pos))?; let new_mountain = new_shape[mountain_idx]; + let mut within_path = tracked.within_path.clone(); for (side, sibling_start, height) in climb_to_peak(tracked.mountain_start, tracked.mountain_height, new_mountain.height) { @@ -303,13 +312,24 @@ impl PartialMmrBelt { .merge_auth .get(&(sibling_start, height)) .ok_or(MmrError::InvalidUpdate)?; - tracked.within_path.push(BeltProofNode { value, side }); + within_path.push(BeltProofNode { value, side }); } - tracked.mountain_start = new_mountain.start; - tracked.mountain_height = new_mountain.height; + new_tracked.insert( + pos, + TrackedLeaf { + leaf: tracked.leaf, + mountain_start: new_mountain.start, + mountain_height: new_mountain.height, + within_path, + }, + ); } + self.peaks = new_peaks; + self.num_leaves = new_num_leaves; + self.tracked = new_tracked; + Ok(()) } } diff --git a/miden-crypto/src/merkle/mmr/belt/mod.rs b/miden-crypto/src/merkle/mmr/belt/mod.rs index 156b6db9de..70993ae807 100644 --- a/miden-crypto/src/merkle/mmr/belt/mod.rs +++ b/miden-crypto/src/merkle/mmr/belt/mod.rs @@ -68,11 +68,7 @@ impl MmrBelt { self.track_mergeable_pair(prev_idx, new_idx); } - let merged = if let Some(right_idx) = self.rightmost_mergeable { - Some(self.merge_pair(right_idx)) - } else { - None - }; + let merged = self.rightmost_mergeable.map(|right_idx| self.merge_pair(right_idx)); let num_merges = usize::from(merged.is_some()); if refresh_bagging { @@ -126,11 +122,6 @@ impl MmrBelt { self.bagging.root() } - #[cfg(test)] - fn bagging_storage_lengths_for_testing(&self) -> [usize; 4] { - self.bagging.storage_lengths() - } - #[cfg(test)] fn last_bagging_update_hashes_for_testing(&self) -> usize { self.last_bagging_update_hashes @@ -486,6 +477,7 @@ pub struct BeltSummary { impl BeltSummary { pub fn from_roots(num_leaves: usize, roots: &[Word]) -> Result { + validate_num_leaves(num_leaves)?; let bagging = BeltBaggingState::from_roots(num_leaves, roots)?; Ok(Self { diff --git a/miden-crypto/src/merkle/mmr/belt/proof.rs b/miden-crypto/src/merkle/mmr/belt/proof.rs index d9179df2e4..25482f185e 100644 --- a/miden-crypto/src/merkle/mmr/belt/proof.rs +++ b/miden-crypto/src/merkle/mmr/belt/proof.rs @@ -1,9 +1,8 @@ use alloc::vec::Vec; use core::ops::Range; -use super::BeltSummary; -use super::shape::*; -use crate::{Word, hash::poseidon2::Poseidon2}; +use super::{BeltSummary, shape::*}; +use crate::Word; #[derive(Debug, Clone, PartialEq, Eq)] pub struct BeltProof { @@ -37,13 +36,9 @@ impl BeltProof { } let root = - self.nodes - .iter() - .zip(&steps) - .fold(self.leaf, |current, (node, side)| match side { - SiblingSide::Left => Poseidon2::merge(&[node.value, current]), - SiblingSide::Right => Poseidon2::merge(&[current, node.value]), - }); + self.nodes.iter().zip(&steps).fold(self.leaf, |current, (node, &side)| { + merge_with_side(side, current, node.value) + }); root == summary.commitment_root() } diff --git a/miden-crypto/src/merkle/mmr/belt/shape.rs b/miden-crypto/src/merkle/mmr/belt/shape.rs index 0a19320741..245da5f520 100644 --- a/miden-crypto/src/merkle/mmr/belt/shape.rs +++ b/miden-crypto/src/merkle/mmr/belt/shape.rs @@ -1,8 +1,22 @@ use alloc::vec::Vec; use core::ops::Range; +use super::super::{Forest, MmrError}; use crate::{EMPTY_WORD, Word, hash::poseidon2::Poseidon2}; +/// Rejects forest sizes past [`Forest::MAX_LEAVES`] so `num_leaves + 1` cannot overflow in the +/// shape helpers, and the public constructors error rather than panic on oversized counts. +pub(super) fn validate_num_leaves(num_leaves: usize) -> Result<(), MmrError> { + if Forest::is_valid_size(num_leaves) { + Ok(()) + } else { + Err(MmrError::ForestSizeExceeded { + requested: num_leaves, + max: Forest::MAX_LEAVES, + }) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum SiblingSide { Left, @@ -23,7 +37,7 @@ pub(super) fn parent_hash_index(child: HashIndex) -> HashIndex { #[cfg(test)] pub(super) fn hash_children(parent: HashIndex) -> (HashIndex, HashIndex) { - debug_assert!(parent.0 % 2 == 0); + debug_assert!(parent.0.is_multiple_of(2)); let span = 1usize << (parent.0.trailing_zeros() as usize - 1); (HashIndex(parent.0 - 3 * span), HashIndex(parent.0 - span)) } @@ -112,28 +126,27 @@ pub(super) fn shape_mountain_index_for_num_leaves( index } -pub(super) fn shape_mountain_index_candidate( - bits: usize, - num_mountains: usize, - position: usize, - target: ShapeMountain, -) -> Option { - let bit = (bits >> position) & 1; - let height = position + bit; - if height != target.height { - return None; - } +/// The mountain at bit `position` (counted from the right) of `bits = num_leaves + 1` (Lemma 6). +fn mountain_at_position(bits: usize, num_mountains: usize, position: usize) -> ShapeMountain { + let height = position + ((bits >> position) & 1); let lower_mask = (1usize << (position + 1)) - 1; let mountain_mask = (1usize << num_mountains) - 1; let higher_mask = mountain_mask ^ lower_mask; let base = (1usize << num_mountains) - (1usize << (position + 1)); let start = base + (bits & higher_mask); - if start != target.start { - return None; - } - Some(num_mountains - 1 - position) + ShapeMountain { start, height } +} + +pub(super) fn shape_mountain_index_candidate( + bits: usize, + num_mountains: usize, + position: usize, + target: ShapeMountain, +) -> Option { + (mountain_at_position(bits, num_mountains, position) == target) + .then_some(num_mountains - 1 - position) } pub(super) fn shape_len_for_num_leaves(num_leaves: usize) -> usize { @@ -156,15 +169,7 @@ pub(super) fn shape_mountain_at_index(num_leaves: usize, index: usize) -> Option } let position = num_mountains - 1 - index; - let bit = (bits >> position) & 1; - let height = position + bit; - let lower_mask = (1usize << (position + 1)) - 1; - let mountain_mask = (1usize << num_mountains) - 1; - let higher_mask = mountain_mask ^ lower_mask; - let base = (1usize << num_mountains) - (1usize << (position + 1)); - let start = base + (bits & higher_mask); - - Some(ShapeMountain { start, height }) + Some(mountain_at_position(bits, num_mountains, position)) } #[cfg(test)] @@ -267,6 +272,14 @@ pub(super) fn bag_belt(range_roots: &[Word]) -> Word { range_roots.iter().fold(EMPTY_WORD, |acc, &root| Poseidon2::merge(&[acc, root])) } +/// Folds `sibling` into `current` on the given side, the shared step of every belt path climb. +pub(super) fn merge_with_side(side: SiblingSide, current: Word, sibling: Word) -> Word { + match side { + SiblingSide::Left => Poseidon2::merge(&[sibling, current]), + SiblingSide::Right => Poseidon2::merge(&[current, sibling]), + } +} + pub(super) fn common_peak_prefix_len(from_num_leaves: usize, to_num_leaves: usize) -> usize { let from_shape = shape_mountains(from_num_leaves); let to_shape = shape_mountains(to_num_leaves); diff --git a/miden-crypto/src/merkle/mmr/belt/tests.rs b/miden-crypto/src/merkle/mmr/belt/tests.rs index bedb8a254f..58eacf035a 100644 --- a/miden-crypto/src/merkle/mmr/belt/tests.rs +++ b/miden-crypto/src/merkle/mmr/belt/tests.rs @@ -1,5 +1,4 @@ use alloc::{vec, vec::Vec}; -use std::println; use super::{ BeltBaggingState, BeltHashArray, BeltSummary, ChangedMountain, HashIndex, MmrBelt, @@ -688,6 +687,75 @@ fn belt_delta_rejects_mutated_tail_peak() { assert!(!delta.verify_transition(&old_summary, &new_summary).unwrap()); } +#[test] +fn belt_delta_verify_rejects_padded_auth_nodes() { + let from = 128usize; + let to = 191usize; + let mut belt = MmrBelt::new(); + for idx in 0..from { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let old_summary = belt.summary(); + + for idx in from..to { + belt.add(int_to_node(idx as u64)).unwrap(); + } + let new_summary = belt.summary(); + let delta = belt.delta(from).unwrap(); + assert!(delta.verify_transition(&old_summary, &new_summary).unwrap()); + + // Padding a valid delta with an auth node outside the required climb set must be rejected, so a + // peer cannot inflate a sync delta with bogus data. + let mut padded_auth = delta.merge_auth_nodes().collect::>(); + padded_auth.push(((usize::MAX, 0), int_to_node(123_456))); + let padded = MmrBeltDelta::from_parts( + delta.from_num_leaves(), + delta.to_num_leaves(), + delta.new_tail_peaks().to_vec(), + padded_auth, + ) + .unwrap(); + + assert!(!padded.verify_transition(&old_summary, &new_summary).unwrap()); +} + +#[test] +fn belt_constructors_reject_oversized_num_leaves() { + // `shape_mountains` computes `num_leaves + 1`; oversized counts must error, not panic. + assert!(BeltSummary::from_roots(usize::MAX, &[]).is_err()); + assert!(PartialMmrBelt::from_peaks(usize::MAX, Vec::new()).is_err()); + assert!(MmrBeltDelta::from_parts(0, usize::MAX, Vec::new(), core::iter::empty()).is_err()); +} + +#[test] +fn partial_belt_apply_is_transactional_on_missing_auth() { + let from = 200usize; + let to = 260usize; + let mut belt = MmrBelt::new(); + for idx in 0..from { + belt.add(int_to_node(idx as u64)).unwrap(); + } + + let mut partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); + for pos in 0..from { + partial.track(&belt.open(pos).unwrap()).unwrap(); + } + + for idx in from..to { + belt.add(int_to_node(idx as u64)).unwrap(); + } + + let mut delta = belt.delta(from).unwrap(); + assert!(delta.num_merge_auth_nodes() > 0, "increment must merge some tracked mountains"); + // Drop the auth nodes a now-merged tracked leaf needs, so the update cannot complete. + delta.merge_auth.clear(); + + let before = partial.clone(); + assert!(partial.apply(&delta).is_err()); + // The failure must leave the view untouched — no partial advance of num_leaves/peaks/tracked. + assert_eq!(partial, before); +} + #[test] fn belt_delta_rejects_wrong_absorbed_old_summary_root() { let from = 128usize; @@ -1148,9 +1216,13 @@ fn partial_belt_delta_merge_auth_is_polylogarithmic() { } } +// Gated behind `std` so the no-std test build (which has no `println!`) still compiles. +#[cfg(feature = "std")] #[test] #[ignore = "prints deterministic MMB issue measurement tables"] fn mmb_issue_measurement_report() { + use std::println; + const STORAGE_SIZES: &[usize] = &[1_000, 65_536, 100_000]; const PROOF_SIZE: usize = 65_536; const DELTA_SIZE: usize = 100_000; @@ -1231,6 +1303,7 @@ fn mmb_issue_measurement_report() { } } +#[cfg(feature = "std")] fn build_mmr_and_belt(size: usize) -> (crate::merkle::mmr::Mmr, MmrBelt) { let leaves = (0..size as u64).map(int_to_node).collect::>(); let mmr = crate::merkle::mmr::Mmr::try_from_iter(leaves.iter().copied()).unwrap(); From 23be33642c46b9728a7029f0f3888ad56104457e Mon Sep 17 00:00:00 2001 From: krushimir <189111540+krushimir@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:44:50 +0200 Subject: [PATCH 07/10] chore: lint --- miden-crypto/src/merkle/mmr/belt/mod.rs | 2 +- miden-crypto/src/merkle/mmr/belt/tests.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/miden-crypto/src/merkle/mmr/belt/mod.rs b/miden-crypto/src/merkle/mmr/belt/mod.rs index 70993ae807..0909d39ec3 100644 --- a/miden-crypto/src/merkle/mmr/belt/mod.rs +++ b/miden-crypto/src/merkle/mmr/belt/mod.rs @@ -462,7 +462,7 @@ impl BeltHashArray { #[cfg(test)] fn value_slot_bytes_for_testing() -> usize { - core::mem::size_of::() + size_of::() } } diff --git a/miden-crypto/src/merkle/mmr/belt/tests.rs b/miden-crypto/src/merkle/mmr/belt/tests.rs index 58eacf035a..af42db1e0c 100644 --- a/miden-crypto/src/merkle/mmr/belt/tests.rs +++ b/miden-crypto/src/merkle/mmr/belt/tests.rs @@ -67,7 +67,7 @@ fn belt_hash_array_preserves_holes_with_compact_value_slots() { assert_eq!(hashes.get(HashIndex(4)), None); assert_eq!(hashes.get(HashIndex(5)), Some(value)); assert_eq!(hashes.get(HashIndex(6)), None); - assert_eq!(BeltHashArray::value_slot_bytes_for_testing(), core::mem::size_of::()); + assert_eq!(BeltHashArray::value_slot_bytes_for_testing(), size_of::()); } #[test] @@ -1112,7 +1112,7 @@ fn belt_delta_from_parts_reconstructs_sync_response() { server_delta.from_num_leaves(), server_delta.to_num_leaves(), server_delta.new_tail_peaks().to_vec(), - server_delta.merge_auth_nodes().collect::>(), + server_delta.merge_auth_nodes(), ) .unwrap(); let summary_roots = server_summary.roots().to_vec(); @@ -1141,7 +1141,7 @@ fn belt_delta_from_parts_rejects_invalid_tail_shape() { server_delta.from_num_leaves(), server_delta.to_num_leaves(), vec![], - server_delta.merge_auth_nodes().collect::>() + server_delta.merge_auth_nodes() ) .is_err() ); From bcba226f8f018fcfe1141492d4309eef487e6f61 Mon Sep 17 00:00:00 2001 From: krushimir <189111540+krushimir@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:29:52 +0200 Subject: [PATCH 08/10] chore: gate belt module behind internal feature --- miden-crypto/src/merkle/mmr/mod.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/miden-crypto/src/merkle/mmr/mod.rs b/miden-crypto/src/merkle/mmr/mod.rs index 6d153a9993..fe6aebbf06 100644 --- a/miden-crypto/src/merkle/mmr/mod.rs +++ b/miden-crypto/src/merkle/mmr/mod.rs @@ -1,9 +1,11 @@ //! Merkle Mountain Range (MMR) data structures. +// The belt prototype is gated behind `internal`, where it is part of the public API (so its items +// are never dead). Compiling it under non-`internal` builds would leave an unused module that trips +// `-D warnings` in the no-default-features feature check. Its tests run via `make test-default`, +// which enables `internal`. #[cfg(feature = "internal")] pub mod belt; -#[cfg(not(feature = "internal"))] -pub(crate) mod belt; mod delta; mod error; mod forest; From a193f8182d27363f22e5b459d3fb2a7403fdfee0 Mon Sep 17 00:00:00 2001 From: krushimir <189111540+krushimir@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:12:58 +0200 Subject: [PATCH 09/10] feat(mmr): add deferred MMB append path --- miden-crypto/benches/mmr_belt.rs | 19 +++--- miden-crypto/src/merkle/mmr/belt/mod.rs | 70 +++++++++++++++++------ miden-crypto/src/merkle/mmr/belt/tests.rs | 34 +++++++++++ 3 files changed, 94 insertions(+), 29 deletions(-) diff --git a/miden-crypto/benches/mmr_belt.rs b/miden-crypto/benches/mmr_belt.rs index 82f14c3c6c..198f2d92bd 100644 --- a/miden-crypto/benches/mmr_belt.rs +++ b/miden-crypto/benches/mmr_belt.rs @@ -58,11 +58,6 @@ fn append_sequence(start: usize) -> Vec { .collect() } -fn rebuilt_belt_summary(belt: &MmrBelt) -> BeltSummary { - let roots = belt.peaks(); - BeltSummary::from_roots(belt.num_leaves(), &roots).unwrap() -} - fn bench_mmr_belt_build(c: &mut Criterion) { let mut group = c.benchmark_group("mmr-belt-build"); configure_group(&mut group); @@ -98,7 +93,7 @@ fn bench_mmr_belt_build(c: &mut Criterion) { |leaves| { let mut belt = MmrBelt::new(); for leaf in leaves { - belt.add_without_bagging_for_benchmark(leaf).unwrap(); + belt.add_deferred(leaf).unwrap(); } hint::black_box(belt); }, @@ -155,7 +150,7 @@ fn bench_mmr_belt_append(c: &mut Criterion) { b.iter_batched_ref( || data.belt.clone(), |belt| { - belt.add_without_bagging_for_benchmark(hint::black_box(next_leaf)).unwrap(); + belt.add_deferred(hint::black_box(next_leaf)).unwrap(); hint::black_box(belt.num_leaves()); }, BatchSize::LargeInput, @@ -210,8 +205,8 @@ fn bench_mmr_belt_append(c: &mut Criterion) { b.iter_batched( || data.belt.clone(), |mut belt| { - belt.add_without_bagging_for_benchmark(hint::black_box(next_leaf)).unwrap(); - hint::black_box(rebuilt_belt_summary(&belt)); + belt.add_deferred(hint::black_box(next_leaf)).unwrap(); + hint::black_box(belt.summary()); }, BatchSize::SmallInput, ); @@ -236,8 +231,8 @@ fn bench_mmr_belt_append(c: &mut Criterion) { b.iter_batched( || data.belt.clone(), |mut belt| { - belt.add_without_bagging_for_benchmark(hint::black_box(next_leaf)).unwrap(); - hint::black_box(rebuilt_belt_summary(&belt).commitment_root()); + belt.add_deferred(hint::black_box(next_leaf)).unwrap(); + hint::black_box(belt.commitment_root()); }, BatchSize::SmallInput, ); @@ -288,7 +283,7 @@ fn bench_mmr_belt_append_sequence(c: &mut Criterion) { || (data.belt.clone(), leaves.clone()), |(belt, leaves)| { for leaf in leaves.iter().copied() { - belt.add_without_bagging_for_benchmark(hint::black_box(leaf)).unwrap(); + belt.add_deferred(hint::black_box(leaf)).unwrap(); } hint::black_box(belt); }, diff --git a/miden-crypto/src/merkle/mmr/belt/mod.rs b/miden-crypto/src/merkle/mmr/belt/mod.rs index 0909d39ec3..99d664c0c5 100644 --- a/miden-crypto/src/merkle/mmr/belt/mod.rs +++ b/miden-crypto/src/merkle/mmr/belt/mod.rs @@ -19,6 +19,7 @@ pub struct MmrBelt { mountains: Vec>, hashes: BeltHashArray, bagging: BeltBaggingState, + bagging_dirty: bool, last_bagging_update_hashes: usize, free_mountain_slots: Vec, head: Option, @@ -36,13 +37,18 @@ impl MmrBelt { self.add_with_bagging_mode(leaf, true) } - /// Benchmark-only append that skips live summary maintenance. + /// Appends a leaf without refreshing the live summary state. /// - /// This leaves [`Self::summary`] and [`Self::commitment_root`] stale. Rebuild a diagnostic - /// summary with [`BeltSummary::from_roots`] after calling this method. + /// [`Self::summary`] and [`Self::commitment_root`] remain safe to call; they rebuild the + /// derived summary from current mountain roots while the live summary state is dirty. + pub fn add_deferred(&mut self, leaf: Word) -> Result { + self.add_with_bagging_mode(leaf, false) + } + + /// Benchmark-only alias for deferred append. #[cfg(any(test, feature = "internal"))] pub fn add_without_bagging_for_benchmark(&mut self, leaf: Word) -> Result { - self.add_with_bagging_mode(leaf, false) + self.add_deferred(leaf) } fn add_with_bagging_mode( @@ -72,23 +78,30 @@ impl MmrBelt { let num_merges = usize::from(merged.is_some()); if refresh_bagging { - let leaf_change = ChangedMountain::new(leaf_position, 0, leaf); - let mut changed = [leaf_change; 2]; - let mut changed_len = 0; - - if let Some(merged) = merged { - changed[changed_len] = merged; - changed_len += 1; - if !merged.contains_position(leaf_position) { + if self.bagging_dirty { + self.rebuild_bagging_state(); + } else { + let leaf_change = ChangedMountain::new(leaf_position, 0, leaf); + let mut changed = [leaf_change; 2]; + let mut changed_len = 0; + + if let Some(merged) = merged { + changed[changed_len] = merged; + changed_len += 1; + if !merged.contains_position(leaf_position) { + changed[changed_len] = leaf_change; + changed_len += 1; + } + } else { changed[changed_len] = leaf_change; changed_len += 1; } - } else { - changed[changed_len] = leaf_change; - changed_len += 1; - } - self.refresh_bagging_state(old_num_leaves, &changed[..changed_len]); + self.refresh_bagging_state(old_num_leaves, &changed[..changed_len]); + } + } else { + self.bagging_dirty = true; + self.last_bagging_update_hashes = 0; } Ok(num_merges) @@ -136,10 +149,18 @@ impl MmrBelt { } pub fn commitment_root(&self) -> Word { + if self.bagging_dirty { + return self.rebuilt_summary().commitment_root(); + } + self.bagging.root() } pub fn summary(&self) -> BeltSummary { + if self.bagging_dirty { + return self.rebuilt_summary(); + } + BeltSummary::from_roots_and_bagging(self.num_leaves, self.peaks(), &self.bagging) .expect("live mountain-order summary must match the current shape") } @@ -188,9 +209,24 @@ impl MmrBelt { fn refresh_bagging_state(&mut self, old_num_leaves: usize, changed: &[ChangedMountain]) { let hashes = self.bagging.append_update(old_num_leaves, self.num_leaves, changed); + self.bagging_dirty = false; + self.last_bagging_update_hashes = hashes; + } + + fn rebuild_bagging_state(&mut self) { + let peaks = self.peaks(); + let (bagging, hashes) = BeltBaggingState::from_roots_with_stats(self.num_leaves, &peaks) + .expect("current mountain roots must match the belt shape"); + self.bagging = bagging; + self.bagging_dirty = false; self.last_bagging_update_hashes = hashes; } + fn rebuilt_summary(&self) -> BeltSummary { + BeltSummary::from_roots(self.num_leaves, &self.peaks()) + .expect("current mountain roots must match the belt shape") + } + pub fn open(&self, position: usize) -> Result { if position >= self.num_leaves { return Err(MmrError::PositionNotFound(position)); diff --git a/miden-crypto/src/merkle/mmr/belt/tests.rs b/miden-crypto/src/merkle/mmr/belt/tests.rs index af42db1e0c..577a907204 100644 --- a/miden-crypto/src/merkle/mmr/belt/tests.rs +++ b/miden-crypto/src/merkle/mmr/belt/tests.rs @@ -556,6 +556,40 @@ fn belt_lazy_bagging_append_keeps_mountain_state() { } } +#[test] +fn belt_deferred_append_summary_matches_live_append() { + let mut live = MmrBelt::new(); + let mut deferred = MmrBelt::new(); + + for idx in 0..512 { + let leaf = int_to_node(idx); + live.add(leaf).unwrap(); + deferred.add_deferred(leaf).unwrap(); + + assert_eq!(deferred.summary(), live.summary()); + assert_eq!(deferred.commitment_root(), live.commitment_root()); + } +} + +#[test] +fn belt_live_append_after_deferred_append_refreshes_summary() { + let mut live = MmrBelt::new(); + let mut mixed = MmrBelt::new(); + + for idx in 0..128 { + let leaf = int_to_node(idx); + live.add(leaf).unwrap(); + if idx % 4 == 3 { + mixed.add(leaf).unwrap(); + } else { + mixed.add_deferred(leaf).unwrap(); + } + + assert_eq!(mixed.summary(), live.summary()); + assert_eq!(mixed.commitment_root(), live.commitment_root()); + } +} + #[test] fn belt_lazy_bagging_append_does_not_touch_live_bagging() { let mut belt = MmrBelt::new(); From 5c3ccefdbb6f947427f5156250d783efd724406b Mon Sep 17 00:00:00 2001 From: krushimir <189111540+krushimir@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:47:42 +0200 Subject: [PATCH 10/10] fix(mmr): reject out-of-range partial belt positions --- miden-crypto/src/merkle/mmr/belt/delta.rs | 15 +- miden-crypto/src/merkle/mmr/belt/tests.rs | 225 +++++++++------------- 2 files changed, 102 insertions(+), 138 deletions(-) diff --git a/miden-crypto/src/merkle/mmr/belt/delta.rs b/miden-crypto/src/merkle/mmr/belt/delta.rs index 3e304f14a6..27ac5beede 100644 --- a/miden-crypto/src/merkle/mmr/belt/delta.rs +++ b/miden-crypto/src/merkle/mmr/belt/delta.rs @@ -234,6 +234,9 @@ impl PartialMmrBelt { } pub fn track(&mut self, proof: &BeltProof) -> Result<(), MmrError> { + // Like `PartialMmr::track`, an out-of-range or otherwise invalid proof is rejected by + // verification (`BeltProof::verify` returns `false` for `position >= num_leaves`) rather + // than by a dedicated position check. if !proof.verify(&self.summary()) { return Err(MmrError::PeakPathMismatch); } @@ -261,6 +264,10 @@ impl PartialMmrBelt { } pub fn open(&self, pos: usize) -> Result, MmrError> { + if pos >= self.num_leaves { + return Err(MmrError::PositionNotFound(pos)); + } + let Some(tracked) = self.tracked.get(&pos) else { return Ok(None); }; @@ -290,7 +297,13 @@ impl PartialMmrBelt { pub fn apply(&mut self, delta: &MmrBeltDelta) -> Result<(), MmrError> { if delta.from_num_leaves() != self.num_leaves { - return Err(MmrError::InvalidUpdate); + // Mirror `PartialMmr::apply`, which reports a descriptive error when a delta does not + // line up with the current state rather than a bare `InvalidUpdate`. + return Err(MmrError::InvalidPeaks(format!( + "delta starts at {} leaves but the partial belt has {} leaves", + delta.from_num_leaves(), + self.num_leaves + ))); } // Build the new state in locals and commit only after every fallible step succeeds. diff --git a/miden-crypto/src/merkle/mmr/belt/tests.rs b/miden-crypto/src/merkle/mmr/belt/tests.rs index 577a907204..ee61a6a9d9 100644 --- a/miden-crypto/src/merkle/mmr/belt/tests.rs +++ b/miden-crypto/src/merkle/mmr/belt/tests.rs @@ -1,14 +1,25 @@ use alloc::{vec, vec::Vec}; +use assert_matches::assert_matches; + use super::{ BeltBaggingState, BeltHashArray, BeltSummary, ChangedMountain, HashIndex, MmrBelt, - MmrBeltDelta, PartialMmrBelt, append_shape_in_place, bag_belt, bag_range, + MmrBeltDelta, MmrError, PartialMmrBelt, append_shape_in_place, bag_belt, bag_range, common_peak_prefix_len, hash_children, leaf_hash_index, node_hash_index, parent_hash_index, shape_mountain_index, shape_mountain_index_for_num_leaves, shape_mountains, shape_range_index_for_mountain, shape_range_index_for_num_leaves, shape_ranges, }; use crate::{EMPTY_WORD, Word, hash::poseidon2::Poseidon2, merkle::int_to_node}; +/// Builds a belt with leaves appended for positions `0..n`. +fn belt_with_leaves(n: usize) -> MmrBelt { + let mut belt = MmrBelt::new(); + for idx in 0..n { + belt.add(int_to_node(idx as u64)).unwrap(); + } + belt +} + fn shape_heights(num_leaves: usize) -> Vec { shape_mountains(num_leaves).iter().map(|mountain| mountain.height).collect() } @@ -252,10 +263,7 @@ fn belt_openings_verify_for_all_leaves() { #[test] fn belt_opening_rejects_wrong_leaf() { - let mut belt = MmrBelt::new(); - for idx in 0..16 { - belt.add(int_to_node(idx)).unwrap(); - } + let belt = belt_with_leaves(16); let summary = belt.summary(); let mut proof = belt.open(7).unwrap(); @@ -266,10 +274,7 @@ fn belt_opening_rejects_wrong_leaf() { #[test] fn belt_proof_rejects_tampered_node() { - let mut belt = MmrBelt::new(); - for idx in 0..37 { - belt.add(int_to_node(idx)).unwrap(); - } + let belt = belt_with_leaves(37); let summary = belt.summary(); let pristine = belt.open(13).unwrap(); @@ -284,10 +289,7 @@ fn belt_proof_rejects_tampered_node() { #[test] fn belt_opening_rejects_wrong_position() { - let mut belt = MmrBelt::new(); - for idx in 0..37 { - belt.add(int_to_node(idx)).unwrap(); - } + let belt = belt_with_leaves(37); let summary = belt.summary(); let mut proof = belt.open(7).unwrap(); @@ -298,28 +300,19 @@ fn belt_opening_rejects_wrong_position() { #[test] fn belt_root_binds_shape_without_belt_domain_separation() { - let mut belt = MmrBelt::new(); - for idx in 0..2 { - belt.add(int_to_node(idx)).unwrap(); - } + let belt = belt_with_leaves(2); let summary = belt.summary(); let peaks = belt.peaks(); assert_eq!(peaks.len(), 1); assert_ne!(summary.root(), peaks[0], "root must not be transparent to its peak"); - let mut bigger = MmrBelt::new(); - for idx in 0..3 { - bigger.add(int_to_node(idx)).unwrap(); - } + let bigger = belt_with_leaves(3); assert_ne!(bigger.summary().root(), summary.root()); } #[test] fn belt_second_bagging_uses_plain_merkle_merge() { - let mut belt = MmrBelt::new(); - for idx in 0..5 { - belt.add(int_to_node(idx)).unwrap(); - } + let belt = belt_with_leaves(5); let shape = shape_mountains(belt.num_leaves()); let peaks = belt.peaks(); @@ -336,10 +329,7 @@ fn belt_second_bagging_uses_plain_merkle_merge() { #[test] fn belt_range_bagging_uses_plain_merkle_merge() { - let mut belt = MmrBelt::new(); - for idx in 0..9 { - belt.add(int_to_node(idx)).unwrap(); - } + let belt = belt_with_leaves(9); let shape = shape_mountains(belt.num_leaves()); let peaks = belt.peaks(); @@ -394,10 +384,7 @@ fn belt_summary_from_roots_matches_full_summary() { #[test] fn belt_summary_exposes_mountain_order_roots() { - let mut belt = MmrBelt::new(); - for idx in 0..37 { - belt.add(int_to_node(idx)).unwrap(); - } + let belt = belt_with_leaves(37); let summary = belt.summary(); let peaks = belt.peaks(); @@ -412,10 +399,7 @@ fn belt_summary_exposes_mountain_order_roots() { #[test] fn belt_summary_uses_maintained_mountain_order_roots() { - let mut belt = MmrBelt::new(); - for idx in 0..37 { - belt.add(int_to_node(idx)).unwrap(); - } + let mut belt = belt_with_leaves(37); let summary = belt.summary(); let roots = belt.peaks(); @@ -428,10 +412,7 @@ fn belt_summary_uses_maintained_mountain_order_roots() { #[test] fn belt_summary_carries_explicit_range_bagging_state() { - let mut belt = MmrBelt::new(); - for idx in 0..190 { - belt.add(int_to_node(idx)).unwrap(); - } + let belt = belt_with_leaves(190); let summary = belt.summary(); let shape = shape_mountains(summary.num_leaves()); @@ -447,10 +428,7 @@ fn belt_summary_carries_explicit_range_bagging_state() { #[test] fn belt_bagging_state_uses_explicit_range_and_belt_nodes() { - let mut belt = MmrBelt::new(); - for idx in 0..190 { - belt.add(int_to_node(idx)).unwrap(); - } + let belt = belt_with_leaves(190); for (nodes, &range_root) in belt.bagging.range_nodes().iter().zip(belt.bagging.range_roots()) { let mut prefix = EMPTY_WORD; @@ -634,10 +612,7 @@ fn belt_live_bagging_update_touches_constant_commitment_hashes() { fn belt_delta_resyncs_client_summary() { let total = 600usize; for from in [0usize, 1, 7, 64, 255, 256, 511] { - let mut belt = MmrBelt::new(); - for idx in 0..from { - belt.add(int_to_node(idx as u64)).unwrap(); - } + let mut belt = belt_with_leaves(from); let client_peaks = belt.peaks(); for idx in from..total { @@ -662,10 +637,7 @@ fn belt_delta_resyncs_client_summary() { fn belt_delta_verifies_summary_transition() { let from = 128usize; let to = 191usize; - let mut belt = MmrBelt::new(); - for idx in 0..from { - belt.add(int_to_node(idx as u64)).unwrap(); - } + let mut belt = belt_with_leaves(from); let old_summary = belt.summary(); for idx in from..to { @@ -681,10 +653,7 @@ fn belt_delta_verifies_summary_transition() { fn belt_delta_rejects_wrong_transition_endpoint() { let from = 64usize; let to = 96usize; - let mut belt = MmrBelt::new(); - for idx in 0..from { - belt.add(int_to_node(idx as u64)).unwrap(); - } + let mut belt = belt_with_leaves(from); let old_summary = belt.summary(); for idx in from..to { @@ -705,10 +674,7 @@ fn belt_delta_rejects_wrong_transition_endpoint() { fn belt_delta_rejects_mutated_tail_peak() { let from = 128usize; let to = 191usize; - let mut belt = MmrBelt::new(); - for idx in 0..from { - belt.add(int_to_node(idx as u64)).unwrap(); - } + let mut belt = belt_with_leaves(from); let old_summary = belt.summary(); for idx in from..to { @@ -725,10 +691,7 @@ fn belt_delta_rejects_mutated_tail_peak() { fn belt_delta_verify_rejects_padded_auth_nodes() { let from = 128usize; let to = 191usize; - let mut belt = MmrBelt::new(); - for idx in 0..from { - belt.add(int_to_node(idx as u64)).unwrap(); - } + let mut belt = belt_with_leaves(from); let old_summary = belt.summary(); for idx in from..to { @@ -765,10 +728,7 @@ fn belt_constructors_reject_oversized_num_leaves() { fn partial_belt_apply_is_transactional_on_missing_auth() { let from = 200usize; let to = 260usize; - let mut belt = MmrBelt::new(); - for idx in 0..from { - belt.add(int_to_node(idx as u64)).unwrap(); - } + let mut belt = belt_with_leaves(from); let mut partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); for pos in 0..from { @@ -790,14 +750,29 @@ fn partial_belt_apply_is_transactional_on_missing_auth() { assert_eq!(partial, before); } +#[test] +fn partial_belt_apply_rejects_delta_with_mismatched_from() { + // Snapshot the partial view at 16 leaves, then grow the belt and build a delta that starts at + // 20 leaves, so the delta's `from` does not line up with the partial's current state. + let mut belt = belt_with_leaves(16); + let mut partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); + + for idx in 16..24 { + belt.add(int_to_node(idx)).unwrap(); + } + let delta = belt.delta(20).unwrap(); + + // Like `PartialMmr::apply`, a delta that does not line up with the current state is rejected + // with a descriptive error rather than a bare `InvalidUpdate`. + let err = partial.apply(&delta).unwrap_err(); + assert_matches!(err, MmrError::InvalidPeaks(_)); +} + #[test] fn belt_delta_rejects_wrong_absorbed_old_summary_root() { let from = 128usize; let to = 191usize; - let mut belt = MmrBelt::new(); - for idx in 0..from { - belt.add(int_to_node(idx as u64)).unwrap(); - } + let mut belt = belt_with_leaves(from); let old_summary = belt.summary(); let common = common_peak_prefix_len(from, to); assert!(common < old_summary.roots().len()); @@ -819,10 +794,7 @@ fn belt_delta_rejects_wrong_absorbed_old_summary_root() { fn belt_delta_transition_requires_merge_auth_for_absorbed_old_roots() { let from = 200usize; let to = 260usize; - let mut belt = MmrBelt::new(); - for idx in 0..from { - belt.add(int_to_node(idx as u64)).unwrap(); - } + let mut belt = belt_with_leaves(from); let old_summary = belt.summary(); for idx in from..to { @@ -840,10 +812,7 @@ fn belt_delta_transition_requires_merge_auth_for_absorbed_old_roots() { fn belt_delta_rejects_forged_merge_auth_value() { let from = 200usize; let to = 260usize; - let mut belt = MmrBelt::new(); - for idx in 0..from { - belt.add(int_to_node(idx as u64)).unwrap(); - } + let mut belt = belt_with_leaves(from); let old_summary = belt.summary(); for idx in from..to { @@ -885,10 +854,7 @@ fn belt_zero_and_single_leaf_summaries() { #[test] fn belt_delta_from_same_leaf_count_is_noop() { - let mut belt = MmrBelt::new(); - for idx in 0..50 { - belt.add(int_to_node(idx)).unwrap(); - } + let belt = belt_with_leaves(50); let summary = belt.summary(); let delta = belt.delta(belt.num_leaves()).unwrap(); @@ -904,10 +870,7 @@ fn belt_delta_from_same_leaf_count_is_noop() { #[test] fn belt_delta_is_logarithmic_in_increment() { - let mut belt = MmrBelt::new(); - for idx in 0..100_000u64 { - belt.add(int_to_node(idx)).unwrap(); - } + let belt = belt_with_leaves(100_000); for k in [1usize, 2, 10, 100, 1000] { let delta = belt.delta(100_000 - k).unwrap(); @@ -922,10 +885,7 @@ fn belt_delta_is_logarithmic_in_increment() { #[test] fn belt_delta_rejects_future_origin() { - let mut belt = MmrBelt::new(); - for idx in 0..10 { - belt.add(int_to_node(idx)).unwrap(); - } + let belt = belt_with_leaves(10); assert!(belt.delta(11).is_err()); } @@ -955,10 +915,7 @@ fn partial_belt_tracks_and_opens_like_full_belt() { #[test] fn partial_belt_from_peaks_rejects_wrong_count() { - let mut belt = MmrBelt::new(); - for idx in 0..7 { - belt.add(int_to_node(idx)).unwrap(); - } + let belt = belt_with_leaves(7); let mut peaks = belt.peaks(); peaks.pop(); assert!(PartialMmrBelt::from_peaks(belt.num_leaves(), peaks).is_err()); @@ -966,10 +923,7 @@ fn partial_belt_from_peaks_rejects_wrong_count() { #[test] fn partial_belt_track_rejects_unauthenticated_proof() { - let mut belt = MmrBelt::new(); - for idx in 0..16 { - belt.add(int_to_node(idx)).unwrap(); - } + let belt = belt_with_leaves(16); let mut partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); let mut proof = belt.open(5).unwrap(); @@ -978,15 +932,26 @@ fn partial_belt_track_rejects_unauthenticated_proof() { assert!(!partial.is_tracked(5)); } +#[test] +fn partial_belt_track_rejects_out_of_range_position() { + let belt = belt_with_leaves(16); + let mut partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); + + let mut proof = belt.open(5).unwrap(); + proof.set_position_for_testing(belt.num_leaves()); + let err = partial.track(&proof).unwrap_err(); + + // Like `PartialMmr::track`, an out-of-range position is rejected by verification rather than a + // dedicated position check. + assert_matches!(err, MmrError::PeakPathMismatch); +} + #[test] fn partial_belt_apply_extends_all_tracks_in_place() { let from = 200usize; let to = 260usize; - let mut belt = MmrBelt::new(); - for idx in 0..from { - belt.add(int_to_node(idx as u64)).unwrap(); - } + let mut belt = belt_with_leaves(from); let mut partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); for position in 0..from { @@ -1011,10 +976,7 @@ fn partial_belt_apply_extends_all_tracks_in_place() { #[test] fn partial_belt_apply_extends_across_many_increments() { - let mut belt = MmrBelt::new(); - for idx in 0..40u64 { - belt.add(int_to_node(idx)).unwrap(); - } + let mut belt = belt_with_leaves(40); let mut partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); let tracked = [0usize, 1, 17, 38, 39]; @@ -1081,10 +1043,7 @@ fn partial_belt_protocol_model_resyncs_after_offline_increment() { fn belt_delta_and_summary_verify_sync_response_transition() { let from = 128usize; let to = 191usize; - let mut belt = MmrBelt::new(); - for idx in 0..from { - belt.add(int_to_node(idx as u64)).unwrap(); - } + let mut belt = belt_with_leaves(from); let old_summary = belt.summary(); for idx in from..to { @@ -1164,10 +1123,7 @@ fn belt_delta_from_parts_reconstructs_sync_response() { #[test] fn belt_delta_from_parts_rejects_invalid_tail_shape() { - let mut belt = MmrBelt::new(); - for idx in 0..191 { - belt.add(int_to_node(idx as u64)).unwrap(); - } + let belt = belt_with_leaves(191); let server_delta = belt.delta(128).unwrap(); assert!( @@ -1185,10 +1141,7 @@ fn belt_delta_from_parts_rejects_invalid_tail_shape() { fn partial_belt_apply_verified_rejects_wrong_new_summary() { let from = 64usize; let to = 96usize; - let mut belt = MmrBelt::new(); - for idx in 0..from { - belt.add(int_to_node(idx as u64)).unwrap(); - } + let mut belt = belt_with_leaves(from); let mut client = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); client.track(&belt.open(from - 1).unwrap()).unwrap(); let old_summary = client.summary(); @@ -1212,10 +1165,7 @@ fn partial_belt_apply_verified_rejects_wrong_new_summary() { fn partial_belt_apply_verified_rejects_missing_merge_auth_without_mutating() { let from = 200usize; let to = 260usize; - let mut belt = MmrBelt::new(); - for idx in 0..from { - belt.add(int_to_node(idx as u64)).unwrap(); - } + let mut belt = belt_with_leaves(from); let mut client = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); client.track(&belt.open(from - 1).unwrap()).unwrap(); let old_summary = client.summary(); @@ -1233,10 +1183,7 @@ fn partial_belt_apply_verified_rejects_missing_merge_auth_without_mutating() { #[test] fn partial_belt_delta_merge_auth_is_polylogarithmic() { - let mut belt = MmrBelt::new(); - for idx in 0..100_000u64 { - belt.add(int_to_node(idx)).unwrap(); - } + let belt = belt_with_leaves(100_000); for k in [1usize, 2, 16, 256, 4096] { let delta = belt.delta(100_000 - k).unwrap(); @@ -1350,14 +1297,21 @@ fn build_mmr_and_belt(size: usize) -> (crate::merkle::mmr::Mmr, MmrBelt) { #[test] fn partial_belt_open_untracked_returns_none() { - let mut belt = MmrBelt::new(); - for idx in 0..16 { - belt.add(int_to_node(idx)).unwrap(); - } + let belt = belt_with_leaves(16); let partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); assert!(partial.open(3).unwrap().is_none()); } +#[test] +fn partial_belt_open_rejects_out_of_range_position() { + let belt = belt_with_leaves(16); + + let partial = PartialMmrBelt::from_peaks(belt.num_leaves(), belt.peaks()).unwrap(); + let err = partial.open(belt.num_leaves()).unwrap_err(); + + assert_matches!(err, MmrError::PositionNotFound(16)); +} + #[test] fn belt_height_sequences_match_paper() { // Golden S_n sequences from arXiv:2511.13582, §3.1 and Figures 5/7/9. @@ -1371,10 +1325,7 @@ fn belt_height_sequences_match_paper() { for (num_leaves, expected) in golden { assert_eq!(shape_heights(num_leaves), expected, "shape S_{num_leaves}"); - let mut belt = MmrBelt::new(); - for idx in 0..num_leaves { - belt.add(int_to_node(idx as u64)).unwrap(); - } + let belt = belt_with_leaves(num_leaves); assert_eq!(belt.mountain_heights(), expected, "live S_{num_leaves}"); } }