Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
8 changes: 8 additions & 0 deletions crates/lib/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
220 changes: 220 additions & 0 deletions crates/lib/core/asm/collections/mmr.masm
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,226 @@ 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,
#! 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, ...]
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 = peak_end (loc.1).
swap 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_from_peak_end` reads it from there.
dup.4 dup.6 mem_store
# => [ROOT, num_leaves, mmr_ptr, ...]

# 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] || 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we either zero the padded peak words here, or assert that they are zero?

unpack_frontier writes all padded words from advice into MMR memory, but root_from_peak_end only authenticates the real peaks before peak_end. So the same (num_leaves, ROOT) can be accepted with different non-zero padding, and a later mmr::pack will hash those padding slots into a different legacy commitment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. unpack_frontier now zeroes the padded peak slots after loading them from advice. The root check still only uses the real peaks, but the memory layout is now canonical, so a later mmr::pack cannot hash non-zero padding.

Added a regression test for this.

# => [R0, R1, C, peak_start+64, ROOT, num_leaves, mmr_ptr, ...]

# 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
adv_pipe
loc_load.0 sub.1 dup loc_store.0 neq.0
end

# 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.
dup.4 loc_load.1 swap exec.root_from_peak_end
# => [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, ...]
Expand Down
32 changes: 32 additions & 0 deletions crates/lib/core/benches/common.rs
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading