Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- Documented the `sorted_array` lookup sortedness contract and added linear assertion helpers for proving word, key, and half-key ordering.
- Documented the `sorted_array` lookup sortedness contract and added linear assertion helpers for proving word, key, and half-key ordering ([#3308](https://github.com/0xMiden/miden-vm/pull/3308)).
- Added trusted sparse MAST forest serialization for trace replay payloads ([#3313](https://github.com/0xMiden/miden-vm/pull/3313)).
- [BREAKING] MAST forest payloads now include sorted root and dependency digests, so commitment inputs round trip ([#3294](https://github.com/0xMiden/miden-vm/pull/3294)).

## v0.24.0 (2026-06-24)

Expand Down
15 changes: 13 additions & 2 deletions core/src/mast/node/basic_block_node/arbitrary.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use alloc::{collections::BTreeMap, sync::Arc};
use alloc::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
};
use core::ops::RangeInclusive;

use proptest::{arbitrary::Arbitrary, prelude::*};
Expand Down Expand Up @@ -474,7 +477,12 @@ impl Arbitrary for MastForest {

// Add external nodes
// WARNING: These use random digests that won't match any valid procedures
let mut external_digest_set = BTreeSet::new();
for digest in external_digests {
if !external_digest_set.insert(digest) {
continue;
}

if let Ok(external_id) =
ExternalNodeBuilder::new(digest).add_to_forest(&mut forest)
{
Expand All @@ -495,8 +503,11 @@ impl Arbitrary for MastForest {

// 4) Make some nodes roots (but not all, to test internal nodes)
let num_roots = (all_node_ids.len() / 3).max(1); // Make roughly 1/3 of nodes roots
let mut root_digest_set = BTreeSet::new();
for (i, &node_id) in all_node_ids.iter().enumerate() {
if i % (all_node_ids.len() / num_roots.max(1)) == 0 {
if i % (all_node_ids.len() / num_roots.max(1)) == 0
&& root_digest_set.insert(forest[node_id].digest())
{
forest.make_root(node_id);
}
}
Expand Down
55 changes: 55 additions & 0 deletions core/src/mast/serialization/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ pub(crate) struct ForestLayout {
node_entry_offset: usize,
external_digest_offset: usize,
node_hash_offset: Option<usize>,
root_commitment_digest_offset: usize,
dependency_commitment_digest_offset: usize,
advice_map_offset: usize,
}

Expand All @@ -51,6 +53,7 @@ pub(super) struct MastForestHeader {
roots_offset: usize,
basic_block_len: usize,
external_digests_len: usize,
root_commitment_digests_len: usize,
node_entries_len: usize,
core_tail_len: usize,
}
Expand Down Expand Up @@ -140,6 +143,14 @@ impl ForestLayout {
pub(super) fn node_hash_offset(&self) -> Option<usize> {
self.node_hash_offset
}

pub(super) fn root_commitment_digest_offset(&self) -> usize {
self.root_commitment_digest_offset
}

pub(super) fn dependency_commitment_digest_offset(&self) -> usize {
self.dependency_commitment_digest_offset
}
}

// WIRE FLAGS
Expand Down Expand Up @@ -217,6 +228,12 @@ impl MastForestHeader {

let roots_count = source.read_usize()?;
validate_budgeted_count(source, roots_count, size_of::<u32>(), "root count")?;
validate_budgeted_count(
source,
roots_count,
crate::Word::min_serialized_size(),
"root commitment digest count",
)?;
let roots_len_bytes = roots_count.checked_mul(size_of::<u32>()).ok_or_else(|| {
DeserializationError::InvalidValue("roots length overflow".to_string())
})?;
Expand All @@ -226,6 +243,13 @@ impl MastForestHeader {
.ok_or_else(|| {
DeserializationError::InvalidValue("external digest length overflow".to_string())
})?;
let root_commitment_digests_len =
roots_count.checked_mul(crate::Word::min_serialized_size()).ok_or_else(|| {
DeserializationError::InvalidValue(
"root commitment digest length overflow".to_string(),
)
})?;
let dependency_commitment_digests_len = external_digests_len;
let node_entries_len =
node_count.checked_mul(MastNodeEntry::SERIALIZED_SIZE).ok_or_else(|| {
DeserializationError::InvalidValue("node entry length overflow".to_string())
Expand All @@ -240,6 +264,11 @@ impl MastForestHeader {
let node_digest_len = external_digests_len.checked_add(node_hash_len).ok_or_else(|| {
DeserializationError::InvalidValue("node digest length overflow".to_string())
})?;
let commitment_digest_len = root_commitment_digests_len
.checked_add(dependency_commitment_digests_len)
.ok_or_else(|| {
DeserializationError::InvalidValue("commitment digest length overflow".to_string())
})?;

// The basic-block section length is encoded after the roots section on the wire.
let roots_offset = source.offset();
Expand All @@ -249,6 +278,7 @@ impl MastForestHeader {
let core_tail_len = basic_block_len
.checked_add(node_digest_len)
.and_then(|len| len.checked_add(node_entries_len))
.and_then(|len| len.checked_add(commitment_digest_len))
.ok_or_else(|| {
DeserializationError::InvalidValue("core payload length overflow".to_string())
})?;
Expand All @@ -263,6 +293,7 @@ impl MastForestHeader {
roots_offset,
basic_block_len,
external_digests_len,
root_commitment_digests_len,
node_entries_len,
core_tail_len,
})
Expand Down Expand Up @@ -387,6 +418,28 @@ fn scan_layout_sections<R: OffsetTrackingReader>(
DeserializationError::InvalidValue("node hash offset overflow".to_string())
})?)
};
let root_commitment_digest_offset = if let Some(node_hash_offset) = node_hash_offset {
let node_hash_len = header
.internal_node_count
.checked_mul(crate::Word::min_serialized_size())
.ok_or_else(|| {
DeserializationError::InvalidValue("node hash length overflow".to_string())
})?;
node_hash_offset.checked_add(node_hash_len).ok_or_else(|| {
DeserializationError::InvalidValue("root commitment digest offset overflow".to_string())
})?
} else {
external_digest_offset.checked_add(header.external_digests_len).ok_or_else(|| {
DeserializationError::InvalidValue("root commitment digest offset overflow".to_string())
})?
};
let dependency_commitment_digest_offset = root_commitment_digest_offset
.checked_add(header.root_commitment_digests_len)
.ok_or_else(|| {
DeserializationError::InvalidValue(
"dependency commitment digest offset overflow".to_string(),
)
})?;
let advice_map_offset =
basic_block_offset.checked_add(header.core_tail_len).ok_or_else(|| {
DeserializationError::InvalidValue("advice map offset overflow".to_string())
Expand All @@ -411,6 +464,8 @@ fn scan_layout_sections<R: OffsetTrackingReader>(
node_entry_offset,
external_digest_offset,
node_hash_offset,
root_commitment_digest_offset,
dependency_commitment_digest_offset,
advice_map_offset,
})
}
Expand Down
87 changes: 77 additions & 10 deletions core/src/mast/serialization/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@
//! - digests for all non-external nodes (`Vec<Word>`, ordered by node index)
//! - lookup is also dense-by-kind: the Nth non-external node uses slot N in this section
//!
//! (Commitment input sections)
//! - root node digests (`Vec<Word>`, sorted by digest)
//! - distinct from procedure roots: roots identify executable nodes by ID; this is the sorted
//! interface commitment input
//! - external node digests (`Vec<Word>`, sorted by digest)
Comment on lines +61 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Similar to the above comments, I'm not sure I understand why we need these new sections. How is data described on line 63 here different from the data described on line 54 above?

@huitseeker huitseeker Jul 4, 2026

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.

The two external digest sections may contain the same values, but they support different access patterns. The structural section is ordered by node index. That follows the earlier goal that a reader can find one node without reading the whole forest. That was a goal in #2623 r2807884038, and #2726 kept the same goal for direct mmap access and size calculation: issue, meeting note. The commitment section is sorted by digest, which gives the commitment code stable input order.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The two external digest sections may contain the same values, but they support different access patterns. The structural section is ordered by node index. That follows the earlier goal that a reader can find one node without reading the whole forest.

For external nodes, the node index doesn't matter - right? External nodes are always leaf nodes and so, we can sort them in arbitrary order. My assumption was that we'd just sort them by digest, and that this was relatively easy to do at the time when we build MAST forest. If that creates complications, we can always just sort them in memory - there is definitely no need to create two separate sections with the same data but different ordering.

//! - distinct from the external digest section: that section is ordered by node index for lookup;
//! this is the sorted dependency commitment input
//!
//! (Advice map section)
//! - Advice map (`AdviceMap`)
//!
Expand Down Expand Up @@ -208,13 +216,14 @@ const FLAGS_RESERVED_MASK: u8 = 0xfd;
/// records. MAST nodes are metadata-free identifiers. Before any public release on this branch,
/// the same unreleased wire version also reserved bit 0 and stopped using it as a forest-level
/// debug-presence flag.
/// - [0, 0, 5]: Added sorted root and dependency digest commitment input sections.
///
/// Legacy wire versions (pre-#3192 decorator terminology):
/// [0,0,1] stored metadata as serialized decorator variants in CSR per-node slots.
/// [0,0,2] removed AssemblyOp from the decorator enum and stored them separately in DebugInfo.
/// [0,0,3] removed the unused decorator-count wire field.
/// [0,0,4] eliminated the decorator wire slots entirely.
const VERSION: [u8; 3] = [0, 0, 4];
const VERSION: [u8; 3] = [0, 0, 5];

// MAST FOREST SERIALIZATION/DESERIALIZATION
// ================================================================================================
Expand Down Expand Up @@ -277,7 +286,7 @@ impl MastForest {
mast_node_entry.write_into(target);
}

for digest in external_digests {
for &digest in &external_digests {
digest.write_into(target);
}

Expand All @@ -287,6 +296,15 @@ impl MastForest {
}
}

for digest in sorted_root_digests(self) {
digest.write_into(target);
}

external_digests.sort_unstable();
for digest in external_digests {
digest.write_into(target);
}

self.advice_map.write_into(target);
}
}
Expand All @@ -295,6 +313,13 @@ pub(super) fn write_hashless_into<W: ByteWriter>(forest: &MastForest, target: &m
forest.write_into_with_options(target, true);
}

fn sorted_root_digests(forest: &MastForest) -> Vec<Word> {
let mut digests: Vec<Word> =
forest.roots.iter().map(|&root_id| forest.nodes[root_id].digest()).collect();
digests.sort_unstable();
digests
}

/// Trusted read backing mode for read-only MAST forest access.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MastForestReadMode {
Expand Down Expand Up @@ -408,6 +433,7 @@ impl<'a> MastForestWireView<'a> {
let (_flags, layout) = read_header_and_scan_layout(&mut scanner, false)?;
let advice_map = WireAdviceMapView::new(bytes, layout.advice_map_offset())?;
check_no_trailing_payload(bytes, advice_map.end_offset())?;
ResolvedSerializedForest::new(bytes, layout)?.validate_commitment_input_sections()?;

Ok(Self {
bytes,
Expand Down Expand Up @@ -672,6 +698,14 @@ impl MastForestWireView<'_> {
self.layout.node_hash_offset()
}

fn root_commitment_digest_offset(&self) -> usize {
self.layout.root_commitment_digest_offset()
}

fn dependency_commitment_digest_offset(&self) -> usize {
self.layout.dependency_commitment_digest_offset()
}

fn digest_slot_at(&self, index: usize) -> usize {
self.resolved()
.expect("digest slots should be readable for a valid serialized view")
Expand Down Expand Up @@ -746,6 +780,7 @@ fn read_usize_at(bytes: &[u8], offset: &mut usize) -> Result<usize, Deserializat
impl Deserializable for MastForest {
fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
let (_flags, forest) = decode_from_reader(source, false)?;
forest.validate_commitment_input_sections()?;
forest.into_materialized()
}

Expand All @@ -761,21 +796,53 @@ impl Deserializable for MastForest {
}

impl super::UntrustedMastForest {
pub(super) fn validate_commitment_input_sections(&self) -> Result<(), DeserializationError> {
validate_commitment_input_sections_from_parts(
&self.bytes,
self.layout,
self.remaining_allocation_budget,
)
}

pub(super) fn into_materialized(self) -> Result<MastForest, DeserializationError> {
let resolved = if let Some(allocation_budget) = self.remaining_allocation_budget {
ResolvedSerializedForest::new_with_allocation_budget(
&self.bytes,
self.layout,
allocation_budget,
)?
let (forest, _bytes, _layout, _remaining_allocation_budget) =
self.into_materialized_with_serialized_parts()?;
Ok(forest)
}

pub(super) fn into_materialized_with_serialized_parts(
self,
) -> Result<(MastForest, Vec<u8>, ForestLayout, Option<usize>), DeserializationError> {
let bytes = self.bytes;
let layout = self.layout;
let advice_map = self.advice_map;
let remaining_allocation_budget = self.remaining_allocation_budget;

let resolved = if let Some(allocation_budget) = remaining_allocation_budget {
ResolvedSerializedForest::new_with_allocation_budget(&bytes, layout, allocation_budget)?
} else {
ResolvedSerializedForest::new(&self.bytes, self.layout)?
ResolvedSerializedForest::new(&bytes, layout)?
};

resolved.materialize(self.advice_map)
let forest = resolved.materialize(advice_map)?;
Ok((forest, bytes, layout, remaining_allocation_budget))
}
}

pub(super) fn validate_commitment_input_sections_from_parts(
bytes: &[u8],
layout: ForestLayout,
remaining_allocation_budget: Option<usize>,
) -> Result<(), DeserializationError> {
let resolved = if let Some(allocation_budget) = remaining_allocation_budget {
ResolvedSerializedForest::new_with_allocation_budget(bytes, layout, allocation_budget)?
} else {
ResolvedSerializedForest::new(bytes, layout)?
};

resolved.validate_commitment_input_sections()
}

pub(super) fn read_untrusted_with_flags<R: ByteReader>(
source: &mut R,
) -> Result<(super::UntrustedMastForest, u8), DeserializationError> {
Expand Down
Loading
Loading