Skip to content
This repository was archived by the owner on Aug 7, 2026. It is now read-only.

feat(merkle): add Merkle frontier - #984

Open
krushimir wants to merge 7 commits into
nextfrom
krushimir/merkle_frontier
Open

krushimir wants to merge 7 commits into
nextfrom
krushimir/merkle_frontier

Conversation

@krushimir

Copy link
Copy Markdown
Collaborator

Describe your changes

This adds MerkleFrontier as append-only len + peaks state with a normal Merkle root commitment, plus append support and conversions to/from legacy MmrPeaks.

It also adds Mmr / PartialMmr helpers for producing plain Merkle proofs against the frontier root, and documents MmrPeaks::hash_peaks() as the legacy commitment path.

Tests added for frontier root computation, append parity with Mmr, legacy conversion, and frontier proof verification.

Closes 0xMiden/miden-vm#3526.

Checklist before requesting a review

  • Repo forked and branch created from next according to naming convention.
  • Commit messages and codestyle follow conventions.
  • Relevant issues are linked in the PR description.
  • Tests added for new functionality.
  • Documentation/comments updated according to changes.

@krushimir
krushimir force-pushed the krushimir/merkle_frontier branch from d477ed9 to 154e6f3 Compare May 4, 2026 13:48
@krushimir
krushimir marked this pull request as ready for review May 4, 2026 15:37

@huitseeker huitseeker left a comment

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.

The code looks great to me. I have further questions:

  • how would our benchmarks evolve on an APi that would consider this the principal commitment API? That includes downstream benches, e.g. I would expect get
    operations will require fewer hash calls when the hamming weight of len is large, but
    unpack itself, which is required for add operations, will require roughly twice as many hash calls. How does that translate on e.g. the transaction benchmark of miden-vm?
  • how complex would it be to have our the downstream APIs, notably the SyncMMR evolve to adopt this?

@krushimir
krushimir force-pushed the krushimir/merkle_frontier branch from 8843c61 to bffac78 Compare May 18, 2026 16:45
@krushimir
krushimir force-pushed the krushimir/merkle_frontier branch from bffac78 to 8ff28bb Compare May 18, 2026 16:46
@krushimir

Copy link
Copy Markdown
Collaborator Author

As a first step, I added a benchmark to get an initial crypto-layer performance datapoint.

It compares MmrPeaks::hash_peaks() vs MerkleFrontier::root(), MerkleFrontier::append(), Mmr::open() vs Mmr::open_frontier(), and verification against the current peak-hash commitment vs the rooted frontier commitment.

For the verification comparison, I’m using:

  • current commitment path: MmrPeaks::hash_peaks() + MmrPeaks::verify()
  • rooted frontier path: MerklePath::verify() against MerkleFrontier::root()
leaves current hash_peaks() + verify() frontier-root MerklePath::verify()
1,000 ~8.13us ~4.75us
1,023 ~8.10us ~4.71us
1,024 ~8.55us ~5.21us
50,000 ~10.96us ~7.58us
65,535 ~11.00us ~7.59us
65,536 ~11.44us ~8.09us

@huitseeker huitseeker left a comment

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.

Thanks for the benchmarks, this is very useful! /cc @bobbinth

Comment thread CHANGELOG.md Outdated
- Added `Signature::from_der()` for EdDSA signatures ([#979](https://github.com/0xMiden/crypto/pull/979)).
- Fixed `SimpleSmt::set_subtree()` to clear stale leaves and inner nodes in the replaced subtree region ([#981](https://github.com/0xMiden/crypto/pull/981)).
- Fixed `SliceReader` bounds checking to reject overflowing read lengths ([#987](https://github.com/0xMiden/crypto/pull/987)).
- Added `MerkleFrontier` as append-only `len + peaks` state with a normal Merkle root commitment, append support, legacy `MmrPeaks` conversion, and standard Merkle proof bridging ([#984](https://github.com/0xMiden/crypto/pull/984)).

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 this move to the 0.26.0 (TBD) section? 0.25.0 was released on 2026-05-01, so this new API would otherwise read like it shipped in the previous release.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Moved to the 0.26.0 section.

///
/// The root is computed by folding the path to `len`: set bits consume peaks on the left and
/// unset bits imply empty subtrees on the right.
pub fn root(&self) -> Word {

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 bind len into this commitment, or make the public check use an authenticated (len, root) pair? The root alone does not distinguish a shorter frontier from one extended by empty leaves.

With normal empty padding, a proof for a leaf past the frontier can still verify when the value is the empty leaf. For example, after one appended empty_subtree_root(0), root() is empty_subtree_root(1), so position 1 with empty_subtree_root(0) verifies against the same root even though len == 1. We may need to include len in the authenticated value, or make the verify API require checking position < len along with the root.

This local unit test repros both the root collision and the out-of-range proof verification:

#[test]
fn test_merkle_frontier_root_collision_does_not_bind_len() {
    let empty_leaf = empty_subtree_root(0);
    let mut mmr = Mmr::new();
    mmr.add(empty_leaf).unwrap();
    mmr.add(empty_leaf).unwrap();

    let frontier_len2 = mmr.frontier();
    let mut frontier_len3 = frontier_len2.clone();
    frontier_len3.append(empty_leaf).unwrap();

    assert_eq!(frontier_len2.len(), 2);
    assert_eq!(frontier_len3.len(), 3);
    assert_eq!(frontier_len2.root(), frontier_len3.root());

    let out_of_range_path = MerklePath::new(vec![empty_leaf, frontier_len2.peaks()[0]]);
    out_of_range_path
        .verify(2, empty_leaf, &frontier_len2.root())
        .unwrap();
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I went with the authenticated (len, root) option.

MerkleFrontier::root() remains the raw Merkle root, and the public verification path now checks position < len before delegating to MerklePath::verify.

I also added your empty-padding repro as a regression: raw root-only verification still demonstrates the ambiguity, while MerkleFrontier::verify() rejects the out-of-range opening.

@krushimir
krushimir force-pushed the krushimir/merkle_frontier branch from 71781a0 to 94b1151 Compare May 21, 2026 13:40
@krushimir
krushimir force-pushed the krushimir/merkle_frontier branch from 94b1151 to 1a7abbf Compare May 21, 2026 13:51
@krushimir

Copy link
Copy Markdown
Collaborator Author

Update after the latest fix: the rooted-frontier verification benchmark now uses the authenticated (len, root) path via MerkleFrontier::verify_leaf(len, root, ...), rather than raw MerklePath::verify() against root() alone.

leaves current hash_peaks() + verify() frontier (len, root) verify_leaf()
1,000 ~13.07us ~7.70us
1,023 ~13.25us ~7.79us
1,024 ~14.03us ~8.61us
50,000 ~18.21us ~12.51us
65,535 ~18.01us ~12.53us
65,536 ~18.76us ~13.28us

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MMR migration: switch from hashed peaks to a rooted Merkle frontier

2 participants