From 41b8239882a9ce9409fc2e4fc9a1f86f1bdc90e6 Mon Sep 17 00:00:00 2001 From: krushimir <189111540+krushimir@users.noreply.github.com> Date: Fri, 22 May 2026 15:19:58 +0200 Subject: [PATCH 1/3] feat(mmr): rooted-frontier commitment with len-bound authentication --- crates/lib/core/Cargo.toml | 8 + crates/lib/core/asm/collections/mmr.masm | 158 ++++++++++++ crates/lib/core/benches/hash_primitives.rs | 214 ++++++++++++++++ crates/lib/core/benches/mmr.rs | 124 +++++++++ crates/lib/core/tests/collections/mmr.rs | 285 +++++++++++++++++++++ 5 files changed, 789 insertions(+) create mode 100644 crates/lib/core/benches/hash_primitives.rs create mode 100644 crates/lib/core/benches/mmr.rs diff --git a/crates/lib/core/Cargo.toml b/crates/lib/core/Cargo.toml index 2337563478..94b25a7c6f 100644 --- a/crates/lib/core/Cargo.toml +++ b/crates/lib/core/Cargo.toml @@ -21,6 +21,14 @@ doctest = false name = "compilation" harness = false +[[bench]] +name = "mmr" +harness = false + +[[bench]] +name = "hash_primitives" +harness = false + [[test]] name = "core-lib" path = "tests/main.rs" diff --git a/crates/lib/core/asm/collections/mmr.masm b/crates/lib/core/asm/collections/mmr.masm index 906b75fd53..4e181d18a2 100644 --- a/crates/lib/core/asm/collections/mmr.masm +++ b/crates/lib/core/asm/collections/mmr.masm @@ -114,6 +114,164 @@ pub proc num_peaks_to_message_size # => [even_count_min, ...] end +#! Computes the raw rooted frontier root of the given MMR. +#! +#! This root does not bind the MMR length by itself. When exposed across trust boundaries, +#! it must be authenticated together with the MMR length stored at `mmr_ptr`. +#! +#! The root folds the path to `num_leaves`: each set bit consumes a peak on the left, while each +#! unset bit folds in an empty subtree on the right. Empty-subtree roots are derived on the fly by +#! squaring (`merge(empty, empty)`), starting from the empty leaf `ZERO`. +#! +#! The accumulator and current empty-subtree root are kept on the operand stack as two words +#! `[ACC, EMPTY]`, with the remaining length bits in `loc.0` and the peak cursor in `loc.1`, so the +#! fold avoids per-iteration memory traffic. Two folding shortcuts keep the hash count minimal: +#! - leading unset bits are folded once instead of twice, since `ACC` and `EMPTY` coincide until +#! the first peak is consumed; +#! - `EMPTY` is squared only while an unset bit still remains, skipping a trailing all-ones run. +#! +#! Input: [mmr_ptr, ...] +#! Output: [ROOT, ...] +@locals(2) +pub proc root + # load num_leaves + dup mem_load + # => [num_leaves, mmr_ptr, ...] + + # empty MMR root is the empty leaf root. + dup eq.0 + if.true + drop drop padw + # => [ZERO, ...] + else + # cursor = mmr_ptr + 4 + 4 * num_peaks, the address just past the last peak (loc.1). + dup u32assert u32popcnt mul.4 movup.2 add add.4 loc_store.1 + # => [num_leaves, ...] + + # remaining length bits (loc.0) + loc_store.0 + # => [...] + + # ACC = EMPTY = empty_subtree_root(0) = ZERO, kept on the stack as [ACC, EMPTY]. + padw padw + # => [ACC, EMPTY, ...] + + # Phase 1: fold the leading unset bits. While the lowest remaining bit is unset, ACC and EMPTY + # both equal empty_subtree_root(level), so a single squaring advances them together. + loc_load.0 is_odd not + while.true + dupw hmerge + loc_load.0 u32shr.1 dup loc_store.0 is_odd not + end + + # The lowest remaining bit is now set, so ACC = empty_subtree_root(level). Seed EMPTY with it. + swapw dropw dupw + # => [ACC, EMPTY, ...] + + # Phase 2: fold the remaining bits, now that ACC and EMPTY have diverged. + push.1 + while.true + loc_load.0 is_odd + if.true + # set bit: ACC = merge(peak, ACC), consuming the next peak. + loc_load.1 sub.4 dup loc_store.1 + padw movup.4 mem_loadw_le + # => [PEAK, ACC, EMPTY, ...] + hmerge + else + # unset bit: ACC = merge(ACC, EMPTY), preserving EMPTY for higher levels. + dupw.1 swapw hmerge + end + # => [ACC, EMPTY, ...] + + # advance bits + loc_load.0 u32shr.1 dup loc_store.0 + # => [bits, ACC, EMPTY, ...] + + # square EMPTY only while an unset bit still remains (bits & (bits + 1) != 0), skipping the + # squaring for a trailing all-ones run where EMPTY is never used again. + dup u32wrapping_add.1 u32and neq.0 + if.true + swapw dupw hmerge swapw + # => [ACC, EMPTY, ...] + end + + loc_load.0 neq.0 + end + + # ACC holds the root. + swapw dropw + # => [ROOT, ...] + end +end + +#! Computes the authenticated rooted frontier pair for the given MMR. +#! +#! The returned root is the raw frontier root from `root`; the MMR length is returned alongside it +#! so callers can authenticate `(num_leaves, root)`. +#! +#! Input: [mmr_ptr, ...] +#! Output: [ROOT, num_leaves, ...] +pub proc root_with_len + dup mem_load + # => [num_leaves, mmr_ptr, ...] + + swap exec.root + # => [ROOT, num_leaves, ...] +end + +#! Authenticates a rooted frontier `(num_leaves, ROOT)` and loads its peaks into memory. +#! +#! The peaks are provided via the advice map keyed by `ROOT`, encoded as `[num_leaves, 0, 0, 0]` +#! followed by the peaks (the same value layout `pack`/`unpack` use). The peaks are loaded into +#! memory at `mmr_ptr` and the frontier root is recomputed over them and asserted equal to `ROOT`. +#! +#! `num_leaves` is taken from the operand stack as a *trusted* input (committed alongside `ROOT`) +#! and is what the recomputed root is folded over, so the pair `(num_leaves, ROOT)` is bound +#! together: a longer frontier padded with empty leaves shares the raw root but is rejected here. +#! +#! On success the MMR is laid out at `mmr_ptr` exactly as `unpack` leaves it, so `get`/`add` can be +#! used against the authenticated peaks. +#! +#! Input: [ROOT, num_leaves, mmr_ptr, ...] +#! Output: [...] +@locals(2) +pub proc unpack_frontier + # store the trusted num_leaves at mmr_ptr; `root` reads the length from there + dup.4 dup.6 mem_store + # => [ROOT, num_leaves, mmr_ptr, ...] + + # loc.0 = write cursor (first peak slot) + dup.5 add.4 loc_store.0 + # loc.1 = number of padded peak words to load + dup.4 u32assert exec.num_leaves_to_num_peaks exec.num_peaks_to_message_size u32div.4 + loc_store.1 + # => [ROOT, num_leaves, mmr_ptr, ...] + + # Pull `[num_leaves, 0, 0, 0] || peaks` to the advice stack and assert the header length matches + # the trusted length from the operand stack. + adv.push_mapval + adv_pushw + dup.8 assert_eq + drop drop drop + + # Load the padded peak words into memory without hashing. + loc_load.1 neq.0 + while.true + padw adv_loadw + loc_load.0 mem_storew_le dropw + loc_load.0 add.4 loc_store.0 + loc_load.1 sub.1 dup loc_store.1 neq.0 + end + + # recompute the frontier root over the trusted length and assert it matches the commitment + dup.5 exec.root + # => [COMPUTED_ROOT, ROOT, num_leaves, mmr_ptr, ...] + assert_eqw + # => [num_leaves, mmr_ptr, ...] + drop drop +end + #! Writes the MMR who's peaks hash to `HASH` to the memory location pointed to by `mmr_ptr`. #! #! Input: [HASH, mmr_ptr, ...] diff --git a/crates/lib/core/benches/hash_primitives.rs b/crates/lib/core/benches/hash_primitives.rs new file mode 100644 index 0000000000..f3e1bb7c49 --- /dev/null +++ b/crates/lib/core/benches/hash_primitives.rs @@ -0,0 +1,214 @@ +use std::hint::black_box; + +use criterion::{ + BatchSize, BenchmarkId, Criterion, SamplingMode, Throughput, criterion_group, criterion_main, +}; +use miden_assembly::Assembler; +use miden_core_lib::CoreLibrary; +use miden_processor::{DefaultHost, FastProcessor, Felt, Program, StackInputs, Word, ZERO}; + +const HASH_COUNTS: &[usize] = &[1, 8, 64, 256]; +const MEM_PTR: u32 = 1000; + +fn hash_primitives(c: &mut Criterion) { + let core_lib = CoreLibrary::default(); + + let mut group = c.benchmark_group("hash-primitives"); + group.sampling_mode(SamplingMode::Flat); + + for hash_count in HASH_COUNTS { + group.throughput(Throughput::Elements(*hash_count as u64)); + + let hmerge = compile(&core_lib, &merge_source(*hash_count, MergeKind::Hmerge)); + group.bench_with_input( + BenchmarkId::new("hmerge-chain", hash_count), + &hmerge, + |bench, program| { + bench.iter_batched( + || processor_inputs(&core_lib), + |(mut host, processor)| { + black_box( + processor + .execute_sync(program, &mut host) + .expect("execute hmerge-chain"), + ); + }, + BatchSize::SmallInput, + ); + }, + ); + + let mtree_merge = compile(&core_lib, &merge_source(*hash_count, MergeKind::MtreeMerge)); + group.bench_with_input( + BenchmarkId::new("mtree-merge-chain", hash_count), + &mtree_merge, + |bench, program| { + bench.iter_batched( + || processor_inputs(&core_lib), + |(mut host, processor)| { + black_box( + processor + .execute_sync(program, &mut host) + .expect("execute mtree-merge-chain"), + ); + }, + BatchSize::SmallInput, + ); + }, + ); + + let poseidon2_merge = + compile(&core_lib, &merge_source(*hash_count, MergeKind::Poseidon2Merge)); + group.bench_with_input( + BenchmarkId::new("poseidon2-merge-chain", hash_count), + &poseidon2_merge, + |bench, program| { + bench.iter_batched( + || processor_inputs(&core_lib), + |(mut host, processor)| { + black_box( + processor + .execute_sync(program, &mut host) + .expect("execute poseidon2-merge-chain"), + ); + }, + BatchSize::SmallInput, + ); + }, + ); + + let hash_double_words = compile(&core_lib, &hash_double_words_source(*hash_count)); + group.bench_with_input( + BenchmarkId::new("hash-double-words-with-mem-init", hash_count), + &hash_double_words, + |bench, program| { + bench.iter_batched( + || processor_inputs(&core_lib), + |(mut host, processor)| { + black_box( + processor + .execute_sync(program, &mut host) + .expect("execute hash-double-words-with-mem-init"), + ); + }, + BatchSize::SmallInput, + ); + }, + ); + } + + group.finish(); +} + +fn compile(core_lib: &CoreLibrary, source: &str) -> Program { + Assembler::default() + .with_static_library(core_lib.library()) + .expect("link core library") + .assemble_program(source) + .expect("assemble benchmark program") +} + +fn processor_inputs(core_lib: &CoreLibrary) -> (DefaultHost, FastProcessor) { + let mut host = DefaultHost::default(); + host.load_library(core_lib).expect("load core library host data"); + (host, FastProcessor::new(StackInputs::default())) +} + +#[derive(Clone, Copy)] +enum MergeKind { + Hmerge, + MtreeMerge, + Poseidon2Merge, +} + +fn merge_source(hash_count: usize, kind: MergeKind) -> String { + assert!(hash_count > 0); + + let import = match kind { + MergeKind::Poseidon2Merge => "use miden::core::crypto::hashes::poseidon2\n", + MergeKind::Hmerge | MergeKind::MtreeMerge => "", + }; + let merge = match kind { + MergeKind::Hmerge => "hmerge", + MergeKind::MtreeMerge => "mtree_merge", + MergeKind::Poseidon2Merge => "exec.poseidon2::merge", + }; + + let mut source = format!( + " + {import} + begin + {} + {} + {merge} + ", + push_word(word_from_u64(1)), + push_word(word_from_u64(2)), + ); + + for _ in 1..hash_count { + source.push_str(&format!( + " + dupw {merge} + " + )); + } + + source.push_str( + " + dropw + end + ", + ); + source +} + +fn hash_double_words_source(double_word_count: usize) -> String { + let end_ptr = MEM_PTR + (double_word_count as u32 * 8); + let mut source = String::from( + " + use miden::core::crypto::hashes::poseidon2 + + begin + ", + ); + + for word_idx in 0..(double_word_count * 2) { + let word = word_from_u64(word_idx as u64 + 1); + source.push_str(&format!( + " + {} push.{} mem_storew_le dropw + ", + push_word(word), + MEM_PTR + word_idx as u32 * 4, + )); + } + + source.push_str(&format!( + " + push.{end_ptr} push.{MEM_PTR} + exec.poseidon2::hash_double_words + dropw + end + " + )); + source +} + +fn push_word(word: Word) -> String { + let [a, b, c, d]: [Felt; 4] = word.into(); + format!( + "push.{}.{}.{}.{}", + d.as_canonical_u64(), + c.as_canonical_u64(), + b.as_canonical_u64(), + a.as_canonical_u64(), + ) +} + +fn word_from_u64(value: u64) -> Word { + [ZERO, ZERO, ZERO, Felt::new_unchecked(value)].into() +} + +criterion_group!(hash_group, hash_primitives); +criterion_main!(hash_group); diff --git a/crates/lib/core/benches/mmr.rs b/crates/lib/core/benches/mmr.rs new file mode 100644 index 0000000000..c948df1a23 --- /dev/null +++ b/crates/lib/core/benches/mmr.rs @@ -0,0 +1,124 @@ +use std::hint::black_box; + +use criterion::{BatchSize, BenchmarkId, Criterion, SamplingMode, criterion_group, criterion_main}; +use miden_assembly::Assembler; +use miden_core_lib::CoreLibrary; +use miden_processor::{DefaultHost, FastProcessor, Felt, Program, StackInputs, Word, ZERO}; + +const MMR_PTR: u32 = 1000; +const MMR_SIZES: &[u32] = &[1_000, 1_023, 1_024, 50_000, 65_535, 65_536]; + +fn mmr_pack_and_root(c: &mut Criterion) { + let core_lib = CoreLibrary::default(); + let mut group = c.benchmark_group("mmr-pack-root"); + group.sampling_mode(SamplingMode::Flat); + + for num_leaves in MMR_SIZES { + let pack = compile(&core_lib, &mmr_source(*num_leaves, "pack")); + group.bench_with_input(BenchmarkId::new("pack", num_leaves), &pack, |bench, program| { + bench.iter_batched( + || processor_inputs(&core_lib), + |(mut host, processor)| { + black_box(processor.execute_sync(program, &mut host).expect("execute pack")); + }, + BatchSize::SmallInput, + ); + }); + + let root = compile(&core_lib, &mmr_source(*num_leaves, "root")); + group.bench_with_input(BenchmarkId::new("root", num_leaves), &root, |bench, program| { + bench.iter_batched( + || processor_inputs(&core_lib), + |(mut host, processor)| { + black_box(processor.execute_sync(program, &mut host).expect("execute root")); + }, + BatchSize::SmallInput, + ); + }); + + let root_with_len = compile(&core_lib, &mmr_source(*num_leaves, "root_with_len")); + group.bench_with_input( + BenchmarkId::new("root-with-len", num_leaves), + &root_with_len, + |bench, program| { + bench.iter_batched( + || processor_inputs(&core_lib), + |(mut host, processor)| { + black_box( + processor + .execute_sync(program, &mut host) + .expect("execute root_with_len"), + ); + }, + BatchSize::SmallInput, + ); + }, + ); + } + + group.finish(); +} + +fn compile(core_lib: &CoreLibrary, source: &str) -> Program { + Assembler::default() + .with_static_library(core_lib.library()) + .expect("link core library") + .assemble_program(source) + .expect("assemble benchmark program") +} + +fn processor_inputs(core_lib: &CoreLibrary) -> (DefaultHost, FastProcessor) { + let mut host = DefaultHost::default(); + host.load_library(core_lib).expect("load core library host data"); + (host, FastProcessor::new(StackInputs::default())) +} + +fn mmr_source(num_leaves: u32, proc_name: &str) -> String { + let mut source = format!( + " + use miden::core::collections::mmr + + begin + push.{num_leaves} push.{MMR_PTR} mem_store drop + " + ); + + for peak_idx in 0..num_leaves.count_ones() { + let peak = word_from_u64(peak_idx as u64 + 1); + source.push_str(&format!( + " + {} push.{} mem_storew_le dropw + ", + push_word(peak), + MMR_PTR + 4 + peak_idx * 4, + )); + } + + source.push_str(&format!( + " + push.{MMR_PTR} exec.mmr::{proc_name} + swapw dropw + end + " + )); + + source +} + +fn push_word(word: Word) -> String { + let [a, b, c, d]: [Felt; 4] = word.into(); + format!( + "push.{}.{}.{}.{}", + d.as_canonical_u64(), + c.as_canonical_u64(), + b.as_canonical_u64(), + a.as_canonical_u64(), + ) +} + +fn word_from_u64(value: u64) -> Word { + [ZERO, ZERO, ZERO, Felt::new_unchecked(value)].into() +} + +criterion_group!(mmr_group, mmr_pack_and_root); +criterion_main!(mmr_group); diff --git a/crates/lib/core/tests/collections/mmr.rs b/crates/lib/core/tests/collections/mmr.rs index 407959a8bd..5a5456d022 100644 --- a/crates/lib/core/tests/collections/mmr.rs +++ b/crates/lib/core/tests/collections/mmr.rs @@ -549,6 +549,260 @@ fn test_mmr_pack() { assert_eq!(advice_data, &expect_data); } +#[test] +fn test_mmr_root_empty() { + let source = " + use miden::core::collections::mmr + + begin + push.0.1000 mem_store drop + push.1000 exec.mmr::root + swapw dropw + end + "; + + build_test!(source).expect_stack(&word_to_ints(&Word::default())); +} + +#[test] +fn test_mmr_root_matches_frontier_fold() { + let cases = [ + // single leaf: no leading zeros, single peak + (1_u64, vec![word_from_u64(1)]), + // single leading zero before the first (and only) set bit, exercising the phase 1 -> 2 seam + (2, vec![word_from_u64(1)]), + // raw-root empty-padding collision shape: len 3 with an empty right leaf + (3, vec![Poseidon2::merge(&[Word::default(), Word::default()]), Word::default()]), + // mixed bits with a trailing all-ones run (skips the empty-square shortcut) + (13, vec![word_from_u64(8), word_from_u64(4), word_from_u64(1)]), + // leading zeros, then a set bit, then a gap of unset bits (exercises empty squaring after + // acc and empty have diverged): 0b101000 + (0b10_1000, vec![word_from_u64(2), word_from_u64(1)]), + // all ones: phase 1 is skipped and empty is never squared + (0b1111_1111_1111_1111_u64, (1..=16).map(word_from_u64).collect::>()), + // power of two: phase 1 folds every leading zero, single peak at the top + (0b1_0000_0000_0000_0000_u64, vec![word_from_u64(17)]), + ]; + + for (num_leaves, peaks) in cases { + let mmr_ptr = 1000_u32; + let mut source = format!( + " + use miden::core::collections::mmr + + begin + push.{num_leaves} push.{mmr_ptr} mem_store drop + " + ); + + for (idx, peak) in peaks.iter().enumerate() { + let stack = word_to_ints(peak); + source.push_str(&format!( + " + push.{}.{}.{}.{} push.{} mem_storew_le dropw + ", + stack[3], + stack[2], + stack[1], + stack[0], + mmr_ptr + 4 + idx as u32 * 4, + )); + } + + source.push_str(&format!( + " + push.{mmr_ptr} exec.mmr::root + swapw dropw + end + " + )); + + let expected_root = mmr_frontier_root(num_leaves as usize, &peaks); + build_test!(&source).expect_stack(&word_to_ints(&expected_root)); + } +} + +#[test] +fn test_mmr_root_with_len_returns_authenticated_pair() { + let num_leaves = 13_u64; + let peaks = vec![word_from_u64(8), word_from_u64(4), word_from_u64(1)]; + let mmr_ptr = 1000_u32; + let mut source = format!( + " + use miden::core::collections::mmr + + begin + push.{num_leaves} push.{mmr_ptr} mem_store drop + " + ); + + for (idx, peak) in peaks.iter().enumerate() { + let stack = word_to_ints(peak); + source.push_str(&format!( + " + push.{}.{}.{}.{} push.{} mem_storew_le dropw + ", + stack[3], + stack[2], + stack[1], + stack[0], + mmr_ptr + 4 + idx as u32 * 4, + )); + } + + source.push_str(&format!( + " + push.{mmr_ptr} exec.mmr::root_with_len + dup.4 push.{num_leaves} assert_eq + movup.4 drop + swapw dropw + end + " + )); + + let expected_root = mmr_frontier_root(num_leaves as usize, &peaks); + build_test!(&source).expect_stack(&word_to_ints(&expected_root)); +} + +#[test] +fn test_mmr_root_with_len_disambiguates_empty_padding_collision() { + let empty_leaf = Word::default(); + let empty_pair = Poseidon2::merge(&[empty_leaf, empty_leaf]); + let peaks_len2 = vec![empty_pair]; + let peaks_len3 = vec![empty_pair, empty_leaf]; + + let root_len2 = mmr_frontier_root(2, &peaks_len2); + let root_len3 = mmr_frontier_root(3, &peaks_len3); + assert_eq!(root_len2, root_len3); + + let mmr_len2_ptr = 1000_u32; + let mmr_len3_ptr = 2000_u32; + let empty_pair = word_to_ints(&empty_pair); + let empty_leaf = word_to_ints(&empty_leaf); + let raw_root = word_to_ints(&root_len2); + + let source = format!( + " + use miden::core::collections::mmr + + begin + push.2 push.{mmr_len2_ptr} mem_store drop + push.{}.{}.{}.{} push.{} mem_storew_le dropw + + push.3 push.{mmr_len3_ptr} mem_store drop + push.{}.{}.{}.{} push.{} mem_storew_le dropw + push.{}.{}.{}.{} push.{} mem_storew_le dropw + + push.{mmr_len2_ptr} exec.mmr::root_with_len + dup.4 push.2 assert_eq + movup.4 drop + push.{}.{}.{}.{} assert_eqw + + push.{mmr_len3_ptr} exec.mmr::root_with_len + dup.4 push.3 assert_eq + movup.4 drop + push.{}.{}.{}.{} assert_eqw + end + ", + empty_pair[3], + empty_pair[2], + empty_pair[1], + empty_pair[0], + mmr_len2_ptr + 4, + empty_pair[3], + empty_pair[2], + empty_pair[1], + empty_pair[0], + mmr_len3_ptr + 4, + empty_leaf[3], + empty_leaf[2], + empty_leaf[1], + empty_leaf[0], + mmr_len3_ptr + 8, + raw_root[3], + raw_root[2], + raw_root[1], + raw_root[0], + raw_root[3], + raw_root[2], + raw_root[1], + raw_root[0], + ); + + build_test!(&source).expect_stack(&[]); +} + +#[test] +fn test_mmr_unpack_frontier_authenticates_pair() { + let num_leaves = 13_u64; + let peaks = [word_from_u64(8), word_from_u64(4), word_from_u64(1)]; + let root = mmr_frontier_root(num_leaves as usize, &peaks); + let mmr_ptr = 1000_u32; + + // advice map value: [num_leaves, 0, 0, 0] || peaks (same layout as `unpack`), keyed by ROOT + let mut value = vec![Felt::new_unchecked(num_leaves), ZERO, ZERO, ZERO]; + let mut padded_peaks = peaks.to_vec(); + padded_peaks.resize(16, Word::default()); + for peak in &padded_peaks { + value.extend_from_slice(&Into::<[Felt; WORD_SIZE]>::into(*peak)); + } + let advice_map: &[(Word, Vec)] = &[(root, value)]; + + // operand stack: [ROOT, num_leaves, mmr_ptr] + let mut stack = word_to_ints(&root); + stack.push(num_leaves); + stack.push(mmr_ptr as u64); + + let source = format!( + " + use miden::core::collections::mmr + begin + push.9.9.9.9 push.{} mem_storew_le dropw + exec.mmr::unpack_frontier + end + ", + mmr_ptr + 16 + ); + let test = build_test!(&source, &stack, &[], MerkleStore::new(), advice_map.iter().cloned()); + + // peaks must be loaded into memory exactly as `unpack` would leave them + let mut expected_memory = vec![num_leaves, 0, 0, 0]; + expected_memory.extend(digests_to_ints(&padded_peaks)); + test.expect_stack_and_memory(&[], mmr_ptr, &expected_memory); +} + +#[test] +fn test_mmr_unpack_frontier_rejects_wrong_len() { + // Same raw root, but a longer (empty-padded) length must be rejected: this is huitseeker's + // empty-padding ambiguity. We commit to `num_leaves + 1` against the root of `num_leaves`. + let num_leaves = 13_u64; + let peaks = [word_from_u64(8), word_from_u64(4), word_from_u64(1)]; + let root = mmr_frontier_root(num_leaves as usize, &peaks); + let mmr_ptr = 1000_u32; + + let tampered_len = num_leaves + 1; + let mut value = vec![Felt::new_unchecked(tampered_len), ZERO, ZERO, ZERO]; + let mut padded_peaks = peaks.to_vec(); + padded_peaks.resize(16, Word::default()); + for peak in &padded_peaks { + value.extend_from_slice(&Into::<[Felt; WORD_SIZE]>::into(*peak)); + } + let advice_map: &[(Word, Vec)] = &[(root, value)]; + + // commit to a tampered length while presenting the genuine root + let mut stack = word_to_ints(&root); + stack.push(tampered_len); + stack.push(mmr_ptr as u64); + + let source = " + use miden::core::collections::mmr + begin exec.mmr::unpack_frontier end + "; + build_test!(source, &stack, &[], MerkleStore::new(), advice_map.iter().cloned()) + .execute() + .expect_err("frontier root must not authenticate a tampered length"); +} + #[test] fn test_mmr_add_single() { let mmr_ptr = 1000; @@ -854,3 +1108,34 @@ fn word_to_ints(word: &Word) -> Vec { let arr: [Felt; WORD_SIZE] = (*word).into(); arr.iter().map(Felt::as_canonical_u64).collect() } + +fn word_from_u64(value: u64) -> Word { + [ZERO, ZERO, ZERO, Felt::new_unchecked(value)].into() +} + +fn mmr_frontier_root(num_leaves: usize, peaks: &[Word]) -> Word { + if num_leaves == 0 { + return Word::default(); + } + + let mut bits = num_leaves; + let mut peak_idx = peaks.len(); + let mut acc = Word::default(); + let mut empty = Word::default(); + + while bits != 0 { + if bits & 1 == 1 { + peak_idx -= 1; + acc = Poseidon2::merge(&[peaks[peak_idx], acc]); + } else { + acc = Poseidon2::merge(&[acc, empty]); + } + + bits >>= 1; + if bits != 0 { + empty = Poseidon2::merge(&[empty, empty]); + } + } + + acc +} From 01e10ea088a162502f4b79a742ec0eb4dd8c6f23 Mon Sep 17 00:00:00 2001 From: krushimir <189111540+krushimir@users.noreply.github.com> Date: Tue, 26 May 2026 19:20:06 +0200 Subject: [PATCH 2/3] perf(mmr): load peaks via unrolled adv_pipe in unpack_frontier --- CHANGELOG.md | 6 + crates/lib/core/asm/collections/mmr.masm | 85 +++++++--- crates/lib/core/benches/common.rs | 32 ++++ crates/lib/core/benches/hash_primitives.rs | 34 +--- crates/lib/core/benches/mmr.rs | 173 ++++++++++++++++++--- crates/lib/core/tests/collections/mmr.rs | 3 +- 6 files changed, 257 insertions(+), 76 deletions(-) create mode 100644 crates/lib/core/benches/common.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 888cff747c..08c0f3de25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +#### Features + +- Added authenticated rooted Merkle-frontier support to the core MMR (`mmr::root`, `mmr::root_with_len`, `mmr::unpack_frontier`) ([#3184](https://github.com/0xMiden/miden-vm/pull/3184)). + ## v0.23.1 (2026-05-20) - Restored metadata-neutral MAST node identity so public procedure roots do not depend on debug/decorator metadata shape; this reopens debug metadata precision issues from #2955 and #3054. diff --git a/crates/lib/core/asm/collections/mmr.masm b/crates/lib/core/asm/collections/mmr.masm index 4e181d18a2..2f4a14b163 100644 --- a/crates/lib/core/asm/collections/mmr.masm +++ b/crates/lib/core/asm/collections/mmr.masm @@ -114,6 +114,20 @@ pub proc num_peaks_to_message_size # => [even_count_min, ...] end +#! Given the num_peaks of a MMR, returns the number of padded peak words. +#! +#! Input: [num_peaks, ...] +#! Output: [num_words, ...] +proc num_peaks_to_padded_words + # the peaks are padded to a minimum length of 16. + push.16 u32max + # => [count_min, ...] + + # when the number of peaks is greater than 16, then they are padded to an even number. + dup is_odd add + # => [even_count_min, ...] +end + #! Computes the raw rooted frontier root of the given MMR. #! #! This root does not bind the MMR length by itself. When exposed across trust boundaries, @@ -132,20 +146,33 @@ end #! #! Input: [mmr_ptr, ...] #! Output: [ROOT, ...] -@locals(2) pub proc root # load num_leaves dup mem_load # => [num_leaves, mmr_ptr, ...] + # peak_end = mmr_ptr + 4 + 4 * num_peaks, the address just past the last peak. + dup u32assert u32popcnt mul.4 movup.2 add add.4 + # => [peak_end, num_leaves, ...] + + swap exec.root_from_peak_end + # => [ROOT, ...] +end + +#! Computes the raw rooted frontier root from a trusted length and peak-end cursor. +#! +#! Input: [num_leaves, peak_end, ...] +#! Output: [ROOT, ...] +@locals(2) +proc root_from_peak_end # empty MMR root is the empty leaf root. dup eq.0 if.true drop drop padw # => [ZERO, ...] else - # cursor = mmr_ptr + 4 + 4 * num_peaks, the address just past the last peak (loc.1). - dup u32assert u32popcnt mul.4 movup.2 add add.4 loc_store.1 + # cursor = peak_end (loc.1). + swap loc_store.1 # => [num_leaves, ...] # remaining length bits (loc.0) @@ -237,35 +264,55 @@ end #! Output: [...] @locals(2) pub proc unpack_frontier - # store the trusted num_leaves at mmr_ptr; `root` reads the length from there + # store the trusted num_leaves at mmr_ptr; `root_from_peak_end` reads it from there. dup.4 dup.6 mem_store # => [ROOT, num_leaves, mmr_ptr, ...] - # loc.0 = write cursor (first peak slot) - dup.5 add.4 loc_store.0 - # loc.1 = number of padded peak words to load - dup.4 u32assert exec.num_leaves_to_num_peaks exec.num_peaks_to_message_size u32div.4 - loc_store.1 + # compute num_peaks once; reuse it for peak_end and the padded-word load count. + dup.4 u32assert exec.num_leaves_to_num_peaks + # => [num_peaks, ROOT, num_leaves, mmr_ptr, ...] + + # loc.1 = peak_end = mmr_ptr + 4 + 4 * num_peaks. + dup mul.4 dup.7 add add.4 loc_store.1 + # => [num_peaks, ROOT, num_leaves, mmr_ptr, ...] + + # loc.0 = remaining padded-peak pairs beyond the 16-word fast path + # (= (padded_words - 16) / 2; zero whenever num_peaks <= 16). + exec.num_peaks_to_padded_words sub.16 u32shr.1 loc_store.0 # => [ROOT, num_leaves, mmr_ptr, ...] - # Pull `[num_leaves, 0, 0, 0] || peaks` to the advice stack and assert the header length matches - # the trusted length from the operand stack. + # Pull `[num_leaves, 0, 0, 0] || padded_peaks` to the advice stack and assert the header length + # matches the trusted length from the operand stack. adv.push_mapval adv_pushw dup.8 assert_eq drop drop drop + # => [ROOT, num_leaves, mmr_ptr, ...] + + # Set up adv_pipe state with cursor = mmr_ptr + 4 at depth 12. + dup.5 add.4 padw padw padw + # => [PAD, PAD, PAD, peak_start, ROOT, num_leaves, mmr_ptr, ...] + + # Load the first 16 padded peak words via 8 unrolled adv_pipes. `adv_pipe` is 1 cycle and writes + # 2 words to memory while leaving the sponge capacity unchanged, so this load is free of + # hash-chiplet rows. + adv_pipe adv_pipe adv_pipe adv_pipe adv_pipe adv_pipe adv_pipe adv_pipe + # => [R0, R1, C, peak_start+64, ROOT, num_leaves, mmr_ptr, ...] - # Load the padded peak words into memory without hashing. - loc_load.1 neq.0 + # Load any extras (padded peaks beyond 16) two at a time. The body never runs when + # num_peaks <= 16, which is the common case. + loc_load.0 neq.0 while.true - padw adv_loadw - loc_load.0 mem_storew_le dropw - loc_load.0 add.4 loc_store.0 - loc_load.1 sub.1 dup loc_store.1 neq.0 + adv_pipe + loc_load.0 sub.1 dup loc_store.0 neq.0 end - # recompute the frontier root over the trusted length and assert it matches the commitment - dup.5 exec.root + # Drop the leftover sponge state and the advanced cursor. + dropw dropw dropw drop + # => [ROOT, num_leaves, mmr_ptr, ...] + + # Recompute the frontier root over the trusted length and assert it matches the commitment. + dup.4 loc_load.1 swap exec.root_from_peak_end # => [COMPUTED_ROOT, ROOT, num_leaves, mmr_ptr, ...] assert_eqw # => [num_leaves, mmr_ptr, ...] diff --git a/crates/lib/core/benches/common.rs b/crates/lib/core/benches/common.rs new file mode 100644 index 0000000000..05fd2bff9f --- /dev/null +++ b/crates/lib/core/benches/common.rs @@ -0,0 +1,32 @@ +use miden_assembly::Assembler; +use miden_core_lib::CoreLibrary; +use miden_processor::{DefaultHost, FastProcessor, Felt, Program, StackInputs, Word, ZERO}; + +pub fn compile(core_lib: &CoreLibrary, source: &str) -> Program { + Assembler::default() + .with_static_library(core_lib.library()) + .expect("link core library") + .assemble_program(source) + .expect("assemble benchmark program") +} + +pub fn processor_inputs(core_lib: &CoreLibrary) -> (DefaultHost, FastProcessor) { + let mut host = DefaultHost::default(); + host.load_library(core_lib).expect("load core library host data"); + (host, FastProcessor::new(StackInputs::default())) +} + +pub fn push_word(word: Word) -> String { + let [a, b, c, d]: [Felt; 4] = word.into(); + format!( + "push.{}.{}.{}.{}", + d.as_canonical_u64(), + c.as_canonical_u64(), + b.as_canonical_u64(), + a.as_canonical_u64(), + ) +} + +pub fn word_from_u64(value: u64) -> Word { + [ZERO, ZERO, ZERO, Felt::new_unchecked(value)].into() +} diff --git a/crates/lib/core/benches/hash_primitives.rs b/crates/lib/core/benches/hash_primitives.rs index f3e1bb7c49..dc00105251 100644 --- a/crates/lib/core/benches/hash_primitives.rs +++ b/crates/lib/core/benches/hash_primitives.rs @@ -3,9 +3,10 @@ use std::hint::black_box; use criterion::{ BatchSize, BenchmarkId, Criterion, SamplingMode, Throughput, criterion_group, criterion_main, }; -use miden_assembly::Assembler; use miden_core_lib::CoreLibrary; -use miden_processor::{DefaultHost, FastProcessor, Felt, Program, StackInputs, Word, ZERO}; + +mod common; +use common::{compile, processor_inputs, push_word, word_from_u64}; const HASH_COUNTS: &[usize] = &[1, 8, 64, 256]; const MEM_PTR: u32 = 1000; @@ -100,20 +101,6 @@ fn hash_primitives(c: &mut Criterion) { group.finish(); } -fn compile(core_lib: &CoreLibrary, source: &str) -> Program { - Assembler::default() - .with_static_library(core_lib.library()) - .expect("link core library") - .assemble_program(source) - .expect("assemble benchmark program") -} - -fn processor_inputs(core_lib: &CoreLibrary) -> (DefaultHost, FastProcessor) { - let mut host = DefaultHost::default(); - host.load_library(core_lib).expect("load core library host data"); - (host, FastProcessor::new(StackInputs::default())) -} - #[derive(Clone, Copy)] enum MergeKind { Hmerge, @@ -195,20 +182,5 @@ fn hash_double_words_source(double_word_count: usize) -> String { source } -fn push_word(word: Word) -> String { - let [a, b, c, d]: [Felt; 4] = word.into(); - format!( - "push.{}.{}.{}.{}", - d.as_canonical_u64(), - c.as_canonical_u64(), - b.as_canonical_u64(), - a.as_canonical_u64(), - ) -} - -fn word_from_u64(value: u64) -> Word { - [ZERO, ZERO, ZERO, Felt::new_unchecked(value)].into() -} - criterion_group!(hash_group, hash_primitives); criterion_main!(hash_group); diff --git a/crates/lib/core/benches/mmr.rs b/crates/lib/core/benches/mmr.rs index c948df1a23..8bf35c7e4c 100644 --- a/crates/lib/core/benches/mmr.rs +++ b/crates/lib/core/benches/mmr.rs @@ -1,9 +1,14 @@ use std::hint::black_box; use criterion::{BatchSize, BenchmarkId, Criterion, SamplingMode, criterion_group, criterion_main}; -use miden_assembly::Assembler; +use miden_core::crypto::hash::Poseidon2; use miden_core_lib::CoreLibrary; -use miden_processor::{DefaultHost, FastProcessor, Felt, Program, StackInputs, Word, ZERO}; +use miden_processor::{ + DefaultHost, FastProcessor, Felt, StackInputs, Word, ZERO, advice::AdviceInputs, +}; + +mod common; +use common::{compile, processor_inputs, push_word, word_from_u64}; const MMR_PTR: u32 = 1000; const MMR_SIZES: &[u32] = &[1_000, 1_023, 1_024, 50_000, 65_535, 65_536]; @@ -54,23 +59,65 @@ fn mmr_pack_and_root(c: &mut Criterion) { ); }, ); + + let unpack_inputs = unpack_bench_inputs(*num_leaves); + + let unpack = compile(&core_lib, &unpack_source(unpack_inputs.hash, "unpack", None)); + group.bench_with_input( + BenchmarkId::new("unpack", num_leaves), + &(unpack, unpack_inputs.legacy_advice_map.clone()), + |bench, (program, advice_map)| { + bench.iter_batched( + || processor_inputs_with_advice(&core_lib, advice_map.clone()), + |(mut host, processor)| { + black_box( + processor.execute_sync(program, &mut host).expect("execute unpack"), + ); + }, + BatchSize::SmallInput, + ); + }, + ); + + let unpack_frontier = compile( + &core_lib, + &unpack_source(unpack_inputs.frontier_root, "unpack_frontier", Some(*num_leaves)), + ); + group.bench_with_input( + BenchmarkId::new("unpack-frontier", num_leaves), + &(unpack_frontier, unpack_inputs.frontier_advice_map), + |bench, (program, advice_map)| { + bench.iter_batched( + || processor_inputs_with_advice(&core_lib, advice_map.clone()), + |(mut host, processor)| { + black_box( + processor + .execute_sync(program, &mut host) + .expect("execute unpack_frontier"), + ); + }, + BatchSize::SmallInput, + ); + }, + ); } group.finish(); } -fn compile(core_lib: &CoreLibrary, source: &str) -> Program { - Assembler::default() - .with_static_library(core_lib.library()) - .expect("link core library") - .assemble_program(source) - .expect("assemble benchmark program") -} - -fn processor_inputs(core_lib: &CoreLibrary) -> (DefaultHost, FastProcessor) { +fn processor_inputs_with_advice( + core_lib: &CoreLibrary, + advice_map: Vec<(Word, Vec)>, +) -> (DefaultHost, FastProcessor) { let mut host = DefaultHost::default(); host.load_library(core_lib).expect("load core library host data"); - (host, FastProcessor::new(StackInputs::default())) + let processor = FastProcessor::new_with_options( + StackInputs::default(), + AdviceInputs::default().with_map(advice_map), + Default::default(), + ) + .expect("processor advice inputs should fit advice map limits"); + (host, processor) } fn mmr_source(num_leaves: u32, proc_name: &str) -> String { @@ -94,30 +141,106 @@ fn mmr_source(num_leaves: u32, proc_name: &str) -> String { )); } - source.push_str(&format!( + source.push_str(&format!("\n push.{MMR_PTR} exec.mmr::{proc_name}\n")); + if proc_name == "root_with_len" { + source.push_str(" movup.4 drop\n"); + } + source.push_str( " - push.{MMR_PTR} exec.mmr::{proc_name} swapw dropw end + ", + ); + + source +} + +fn unpack_source(commitment: Word, proc_name: &str, num_leaves: Option) -> String { + let mut source = format!( + " + use miden::core::collections::mmr + + begin + push.{MMR_PTR} + " + ); + if let Some(num_leaves) = num_leaves { + source.push_str(&format!(" push.{num_leaves}\n")); + } + source.push_str(&format!( " + {} + exec.mmr::{proc_name} + end + ", + push_word(commitment), )); source } -fn push_word(word: Word) -> String { - let [a, b, c, d]: [Felt; 4] = word.into(); - format!( - "push.{}.{}.{}.{}", - d.as_canonical_u64(), - c.as_canonical_u64(), - b.as_canonical_u64(), - a.as_canonical_u64(), - ) +struct UnpackBenchInputs { + hash: Word, + frontier_root: Word, + legacy_advice_map: Vec<(Word, Vec)>, + frontier_advice_map: Vec<(Word, Vec)>, +} + +fn unpack_bench_inputs(num_leaves: u32) -> UnpackBenchInputs { + let peak_count = num_leaves.count_ones() as usize; + let peaks: Vec = (0..peak_count).map(|idx| word_from_u64(idx as u64 + 1)).collect(); + let mut padded_peaks = peaks.clone(); + padded_peaks.resize(padded_peak_count(peak_count), Word::default()); + + let hash = Poseidon2::hash_elements(Word::words_as_elements(&padded_peaks)); + let frontier_root = mmr_frontier_root(num_leaves as usize, &peaks); + + let mut advice_value = Vec::with_capacity(Word::NUM_ELEMENTS + padded_peaks.len()); + advice_value.extend_from_slice(&[Felt::new_unchecked(num_leaves as u64), ZERO, ZERO, ZERO]); + advice_value.extend_from_slice(Word::words_as_elements(&padded_peaks)); + + UnpackBenchInputs { + hash, + frontier_root, + legacy_advice_map: vec![(hash, advice_value.clone())], + frontier_advice_map: vec![(frontier_root, advice_value)], + } } -fn word_from_u64(value: u64) -> Word { - [ZERO, ZERO, ZERO, Felt::new_unchecked(value)].into() +fn padded_peak_count(peak_count: usize) -> usize { + let peak_count = peak_count.max(16); + if peak_count > 16 && peak_count % 2 == 1 { + peak_count + 1 + } else { + peak_count + } +} + +fn mmr_frontier_root(num_leaves: usize, peaks: &[Word]) -> Word { + if num_leaves == 0 { + return Word::default(); + } + + let mut bits = num_leaves; + let mut peak_idx = peaks.len(); + let mut acc = Word::default(); + let mut empty = Word::default(); + + while bits != 0 { + if bits & 1 == 1 { + peak_idx -= 1; + acc = Poseidon2::merge(&[peaks[peak_idx], acc]); + } else { + acc = Poseidon2::merge(&[acc, empty]); + } + + bits >>= 1; + if bits != 0 { + empty = Poseidon2::merge(&[empty, empty]); + } + } + + acc } criterion_group!(mmr_group, mmr_pack_and_root); diff --git a/crates/lib/core/tests/collections/mmr.rs b/crates/lib/core/tests/collections/mmr.rs index 5a5456d022..be37780053 100644 --- a/crates/lib/core/tests/collections/mmr.rs +++ b/crates/lib/core/tests/collections/mmr.rs @@ -569,7 +569,8 @@ fn test_mmr_root_matches_frontier_fold() { let cases = [ // single leaf: no leading zeros, single peak (1_u64, vec![word_from_u64(1)]), - // single leading zero before the first (and only) set bit, exercising the phase 1 -> 2 seam + // single leading zero before the first (and only) set bit, exercising the phase 1 -> 2 + // seam (2, vec![word_from_u64(1)]), // raw-root empty-padding collision shape: len 3 with an empty right leaf (3, vec![Poseidon2::merge(&[Word::default(), Word::default()]), Word::default()]), From bb9330763798b97569dc55fd9669eeece08c91c4 Mon Sep 17 00:00:00 2001 From: krushimir <189111540+krushimir@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:35:43 +0200 Subject: [PATCH 3/3] fix(mmr): canonicalize frontier padding on unpack --- crates/lib/core/asm/collections/mmr.masm | 19 +++++++++++-- crates/lib/core/tests/collections/mmr.rs | 35 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/lib/core/asm/collections/mmr.masm b/crates/lib/core/asm/collections/mmr.masm index 2f4a14b163..cd8996450c 100644 --- a/crates/lib/core/asm/collections/mmr.masm +++ b/crates/lib/core/asm/collections/mmr.masm @@ -307,8 +307,23 @@ pub proc unpack_frontier loc_load.0 sub.1 dup loc_store.0 neq.0 end - # Drop the leftover sponge state and the advanced cursor. - dropw dropw dropw drop + # Drop the leftover sponge state, keeping the advanced adv_pipe cursor (= padded_end) on top. + dropw dropw dropw + # => [padded_end, ROOT, num_leaves, mmr_ptr, ...] + + # Canonicalize padding slots `[peak_end, padded_end)`. `root_from_peak_end` authenticates only the + # real peaks, but `pack` hashes the full padded layout, so padding must not retain advice garbage. + # A resident ZERO word and the cursor stay on the stack (no per-iteration locals), and the stored + # word is all-zero so `mem_storew_be` (1-3 cycles) is equivalent to `_le` (8-9) but cheaper. + padw loc_load.1 + # => [peak_end, ZERO, padded_end, ...] + dup dup.6 neq + while.true + dup movdn.5 mem_storew_be movup.4 add.4 + # => [cursor+4, ZERO, padded_end, ...] + dup dup.6 neq + end + drop dropw drop # => [ROOT, num_leaves, mmr_ptr, ...] # Recompute the frontier root over the trusted length and assert it matches the commitment. diff --git a/crates/lib/core/tests/collections/mmr.rs b/crates/lib/core/tests/collections/mmr.rs index be37780053..6a6bcd578e 100644 --- a/crates/lib/core/tests/collections/mmr.rs +++ b/crates/lib/core/tests/collections/mmr.rs @@ -772,6 +772,41 @@ fn test_mmr_unpack_frontier_authenticates_pair() { test.expect_stack_and_memory(&[], mmr_ptr, &expected_memory); } +#[test] +fn test_mmr_unpack_frontier_canonicalizes_padding() { + let num_leaves = 13_u64; + let peaks = [word_from_u64(8), word_from_u64(4), word_from_u64(1)]; + let root = mmr_frontier_root(num_leaves as usize, &peaks); + let mmr_ptr = 1000_u32; + + let mut advised_peaks = peaks.to_vec(); + while advised_peaks.len() < 16 { + advised_peaks.push(word_from_u64(100 + advised_peaks.len() as u64)); + } + + let mut value = vec![Felt::new_unchecked(num_leaves), ZERO, ZERO, ZERO]; + for peak in &advised_peaks { + value.extend_from_slice(&Into::<[Felt; WORD_SIZE]>::into(*peak)); + } + let advice_map: &[(Word, Vec)] = &[(root, value)]; + + let mut stack = word_to_ints(&root); + stack.push(num_leaves); + stack.push(mmr_ptr as u64); + + let source = " + use miden::core::collections::mmr + begin exec.mmr::unpack_frontier end + "; + let test = build_test!(source, &stack, &[], MerkleStore::new(), advice_map.iter().cloned()); + + let mut canonical_peaks = peaks.to_vec(); + canonical_peaks.resize(16, Word::default()); + let mut expected_memory = vec![num_leaves, 0, 0, 0]; + expected_memory.extend(digests_to_ints(&canonical_peaks)); + test.expect_stack_and_memory(&[], mmr_ptr, &expected_memory); +} + #[test] fn test_mmr_unpack_frontier_rejects_wrong_len() { // Same raw root, but a longer (empty-padded) length must be rejected: this is huitseeker's