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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## v0.16.0 (TBD)

### Features

- [BREAKING] Constructed a `BatchNoteTree` over the batch's output notes during batch building: `ProposedBatch` exposes it via `batch_note_tree()` and includes it in `into_parts`, and `ProvenBatch` carries its root via `note_tree_root()` (changes the `ProvenBatch` serialization format and `new_unchecked` signature) ([#3022](https://github.com/0xMiden/protocol/pull/3022)).

### Changes

- [BREAKING] Renamed `AccountStorageDelta` to `AccountStoragePatch` ([#3002](https://github.com/0xMiden/protocol/pull/3002)).
Expand Down
25 changes: 24 additions & 1 deletion crates/miden-protocol/src/batch/proposed_batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use alloc::vec::Vec;

use crate::account::AccountId;
use crate::batch::note_tracker::{NoteTracker, TrackerOutput};
use crate::batch::{BatchAccountUpdate, BatchId};
use crate::batch::{BatchAccountUpdate, BatchId, BatchNoteTree};
use crate::block::{BlockHeader, BlockNumber};
use crate::errors::ProposedBatchError;
use crate::note::{NoteId, NoteInclusionProof};
Expand Down Expand Up @@ -63,6 +63,10 @@ pub struct ProposedBatch {
/// batch that are not consumed within the same batch. These are sorted by
/// [`OutputNote::id`].
output_notes: Vec<OutputNote>,
/// The [`BatchNoteTree`] built over the batch's output notes, with note IDs packed at
/// contiguous leaf indices in the same order as `output_notes`. Its root is the batch's
/// note tree root.
Comment on lines +66 to +68

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.

Suggested change
/// The [`BatchNoteTree`] built over the batch's output notes, with note IDs packed at
/// contiguous leaf indices in the same order as `output_notes`. Its root is the batch's
/// note tree root.
/// The [`BatchNoteTree`] built over the batch's output notes, with note IDs packed at
/// contiguous leaf indices in the same order as `output_notes`.

batch_note_tree: BatchNoteTree,
}

impl ProposedBatch {
Expand Down Expand Up @@ -320,6 +324,14 @@ impl ProposedBatch {
return Err(ProposedBatchError::TooManyOutputNotes(output_notes.len()));
}

// Build the batch note tree over the final output notes. `MAX_OUTPUT_NOTES_PER_BATCH`
// equals the tree's capacity (`2^BATCH_NOTE_TREE_DEPTH`) and the check above bounds the
// number of output notes by it, so this is a defensive error path that cannot be triggered
// in practice.
let batch_note_tree =
BatchNoteTree::with_contiguous_leaves(output_notes.iter().map(Into::into))
.map_err(ProposedBatchError::BatchNoteTreeConstructionFailed)?;

// Compute batch ID.
// --------------------------------------------------------------------------------------------

Expand All @@ -335,6 +347,7 @@ impl ProposedBatch {
batch_expiration_block_num,
input_notes,
output_notes,
batch_note_tree,
})
}

Expand Down Expand Up @@ -447,6 +460,11 @@ impl ProposedBatch {
&self.output_notes
}

/// Returns the [`BatchNoteTree`] built over the batch's output notes.
pub fn batch_note_tree(&self) -> &BatchNoteTree {
&self.batch_note_tree
}

/// Consumes the proposed batch and returns its underlying parts.
#[allow(clippy::type_complexity)]
pub fn into_parts(
Expand All @@ -461,6 +479,7 @@ impl ProposedBatch {
InputNotes<InputNoteCommitment>,
Vec<OutputNote>,
BlockNumber,
BatchNoteTree,
) {
(
self.transactions,
Expand All @@ -472,6 +491,7 @@ impl ProposedBatch {
self.input_notes,
self.output_notes,
self.batch_expiration_block_num,
self.batch_note_tree,
)
}
}
Expand Down Expand Up @@ -611,6 +631,9 @@ mod tests {
assert_eq!(batch.batch_expiration_block_num, batch2.batch_expiration_block_num);
assert_eq!(batch.input_notes, batch2.input_notes);
assert_eq!(batch.output_notes, batch2.output_notes);
// The batch note tree is not serialized but deterministically recomputed on
// deserialization.
assert_eq!(batch.batch_note_tree, batch2.batch_note_tree);

Ok(())
}
Expand Down
23 changes: 23 additions & 0 deletions crates/miden-protocol/src/batch/proven_batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ pub struct ProvenBatch {
account_updates: BTreeMap<AccountId, BatchAccountUpdate>,
input_notes: InputNotes<InputNoteCommitment>,
output_notes: Vec<OutputNote>,
/// The root of the [`BatchNoteTree`](crate::batch::BatchNoteTree) built over `output_notes`.
///
/// This value is stored as-is and is **not** recomputed from or checked against `output_notes`
/// by [`ProvenBatch::new_unchecked`] or deserialization. It must therefore not be trusted at a
/// trust boundary until a consumer binds it to `output_notes` (e.g. the block kernel, which
/// will verify it against the per-batch note tree it reconstructs).
note_tree_root: Word,
batch_expiration_block_num: BlockNumber,
transactions: OrderedTransactionHeaders,
}
Expand All @@ -45,13 +52,15 @@ impl ProvenBatch {
///
/// Returns an error if the batch expiration block number is not greater than the reference
/// block number.
#[allow(clippy::too_many_arguments)]
pub fn new_unchecked(
id: BatchId,
reference_block_commitment: Word,
reference_block_num: BlockNumber,
account_updates: BTreeMap<AccountId, BatchAccountUpdate>,
input_notes: InputNotes<InputNoteCommitment>,
output_notes: Vec<OutputNote>,
note_tree_root: Word,
batch_expiration_block_num: BlockNumber,
transactions: OrderedTransactionHeaders,
) -> Result<Self, ProvenBatchError> {
Expand All @@ -70,6 +79,7 @@ impl ProvenBatch {
account_updates,
input_notes,
output_notes,
note_tree_root,
batch_expiration_block_num,
transactions,
})
Expand Down Expand Up @@ -139,6 +149,16 @@ impl ProvenBatch {
&self.output_notes
}

/// Returns the root of the [`BatchNoteTree`](crate::batch::BatchNoteTree) built over the
/// batch's output notes.
///
/// This root is not validated against [`Self::output_notes`] by the constructor or
/// deserialization, so it must not be trusted at a trust boundary until a consumer binds it to
/// the output notes.
pub fn note_tree_root(&self) -> Word {
self.note_tree_root
}

/// Returns the [`OrderedTransactionHeaders`] included in this batch.
pub fn transactions(&self) -> &OrderedTransactionHeaders {
&self.transactions
Expand All @@ -164,6 +184,7 @@ impl Serializable for ProvenBatch {
self.account_updates.write_into(target);
self.input_notes.write_into(target);
self.output_notes.write_into(target);
self.note_tree_root.write_into(target);
self.batch_expiration_block_num.write_into(target);
self.transactions.write_into(target);
}
Expand All @@ -177,6 +198,7 @@ impl Deserializable for ProvenBatch {
let account_updates = BTreeMap::read_from(source)?;
let input_notes = InputNotes::<InputNoteCommitment>::read_from(source)?;
let output_notes = Vec::<OutputNote>::read_from(source)?;
let note_tree_root = Word::read_from(source)?;
let batch_expiration_block_num = BlockNumber::read_from(source)?;
let transactions = OrderedTransactionHeaders::read_from(source)?;

Expand All @@ -187,6 +209,7 @@ impl Deserializable for ProvenBatch {
account_updates,
input_notes,
output_notes,
note_tree_root,
batch_expiration_block_num,
transactions,
)
Expand Down
3 changes: 3 additions & 0 deletions crates/miden-protocol/src/errors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,9 @@ pub enum ProposedBatchError {
)]
TooManyOutputNotes(usize),

#[error("failed to construct the batch note tree from the batch's output notes")]
BatchNoteTreeConstructionFailed(#[source] MerkleError),

#[error(
"transaction batch has {0} account updates but at most {MAX_ACCOUNTS_PER_BATCH} are allowed"
)]
Expand Down
147 changes: 146 additions & 1 deletion crates/miden-testing/src/kernel_tests/batch/proposed_batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use miden_crypto::rand::RandomCoin;
use miden_protocol::Word;
use miden_protocol::account::{Account, AccountId, AccountType};
use miden_protocol::asset::NonFungibleAsset;
use miden_protocol::batch::ProposedBatch;
use miden_protocol::batch::{BatchNoteTree, ProposedBatch, ProvenBatch};
use miden_protocol::block::BlockNumber;
use miden_protocol::crypto::merkle::MerkleError;
use miden_protocol::errors::{BatchAccountUpdateError, ProposedBatchError};
Expand All @@ -30,6 +30,7 @@ use miden_protocol::transaction::{
ProvenTransaction,
RawOutputNote,
};
use miden_protocol::utils::serde::{Deserializable, Serializable};
use miden_standards::account::interface::{AccountInterface, AccountInterfaceExt};
use miden_standards::note::P2idNoteStorage;
use miden_standards::testing::account_component::MockAccountComponent;
Expand Down Expand Up @@ -229,6 +230,150 @@ fn note_created_and_consumed_in_same_batch() -> anyhow::Result<()> {
Ok(())
}

/// Tests that the batch note tree is built over the batch's final output notes: its root matches a
/// tree built independently from `output_notes()` and it has one leaf per output note.
#[test]
fn batch_note_tree_built_over_output_notes() -> anyhow::Result<()> {
let TestSetup { mut chain, account1, .. } = setup_chain();
let block1 = chain.block_header(1);
let block2 = chain.prove_next_block()?;

let output_notes = (40..43).map(mock_output_note).collect::<alloc::vec::Vec<_>>();
let tx =
MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment())
.reference_block(&block1)
.output_notes(output_notes.clone())
.build()?;

let batch = ProposedBatch::new_unverified(
[tx].into_iter().map(Arc::new).collect(),
block2.header().clone(),
chain.latest_partial_blockchain(),
BTreeMap::default(),
)?;

assert_eq!(batch.output_notes().len(), output_notes.len());

let expected_tree =
BatchNoteTree::with_contiguous_leaves(batch.output_notes().iter().map(Into::into))?;
assert_eq!(batch.batch_note_tree().root(), expected_tree.root());
assert_eq!(batch.batch_note_tree().num_leaves(), output_notes.len());
assert_ne!(
batch.batch_note_tree().root(),
BatchNoteTree::with_contiguous_leaves([])?.root()
);

Ok(())
}

/// Tests that notes erased within a batch (created and consumed in the same batch) are excluded
/// from the batch note tree, so its root matches a tree built from the post-erasure output notes.
#[test]
fn batch_note_tree_excludes_erased_notes() -> anyhow::Result<()> {
let TestSetup { mut chain, account1, account2, .. } = setup_chain();
let block1 = chain.block_header(1);
let block2 = chain.prove_next_block()?;

// tx1 creates an erased note (consumed by tx2) and a kept note.
let erased_note = mock_note(40);
let kept_note = mock_output_note(41);
let tx1 =
MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment())
.reference_block(&block1)
.output_notes(vec![
RawOutputNote::Full(erased_note.clone()).into_output_note().unwrap(),
kept_note.clone(),
])
.build()?;
let tx2 =
MockProvenTxBuilder::with_account(account2.id(), Word::empty(), account2.to_commitment())
.reference_block(&block1)
.unauthenticated_notes(vec![erased_note.clone()])
.build()?;

let batch = ProposedBatch::new_unverified(
[tx1, tx2].into_iter().map(Arc::new).collect(),
block2.header().clone(),
chain.latest_partial_blockchain(),
BTreeMap::default(),
)?;

// Only the kept note survives erasure.
assert_eq!(batch.output_notes(), slice::from_ref(&kept_note));
assert_eq!(batch.batch_note_tree().num_leaves(), 1);

let expected_tree =
BatchNoteTree::with_contiguous_leaves(slice::from_ref(&kept_note).iter().map(Into::into))?;
assert_eq!(batch.batch_note_tree().root(), expected_tree.root());

Ok(())
}

/// Tests that a batch without output notes has an empty batch note tree, and that the root carried
/// on the proven batch matches the proposed batch's tree root.
#[test]
fn batch_note_tree_empty_when_no_output_notes() -> anyhow::Result<()> {
let TestSetup { mut chain, account1, .. } = setup_chain();
let block1 = chain.block_header(1);
let block2 = chain.prove_next_block()?;

let tx =
MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment())
.reference_block(&block1)
.build()?;

let proposed_batch = ProposedBatch::new_unverified(
[tx].into_iter().map(Arc::new).collect(),
block2.header().clone(),
chain.latest_partial_blockchain(),
BTreeMap::default(),
)?;

assert_eq!(proposed_batch.output_notes().len(), 0);
assert_eq!(proposed_batch.batch_note_tree().num_leaves(), 0);
assert_eq!(
proposed_batch.batch_note_tree().root(),
BatchNoteTree::with_contiguous_leaves([])?.root(),
);

// The proven batch carries the same note tree root.
let proven_batch = chain.prove_transaction_batch(proposed_batch.clone())?;
assert_eq!(proven_batch.note_tree_root(), proposed_batch.batch_note_tree().root());

Ok(())
}

/// Tests that a proven batch's note tree root survives a serialization round-trip.
#[test]
fn proven_batch_serialization_preserves_note_tree_root() -> anyhow::Result<()> {
let TestSetup { mut chain, account1, .. } = setup_chain();
let block1 = chain.block_header(1);
let block2 = chain.prove_next_block()?;

let output_notes = (40..43).map(mock_output_note).collect::<alloc::vec::Vec<_>>();
let tx =
MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment())
.reference_block(&block1)
.output_notes(output_notes)
.build()?;

let proposed_batch = ProposedBatch::new_unverified(
[tx].into_iter().map(Arc::new).collect(),
block2.header().clone(),
chain.latest_partial_blockchain(),
BTreeMap::default(),
)?;
let expected_root = proposed_batch.batch_note_tree().root();
let proven_batch = chain.prove_transaction_batch(proposed_batch)?;
assert_eq!(proven_batch.note_tree_root(), expected_root);

let deserialized = ProvenBatch::read_from_bytes(&proven_batch.to_bytes()).unwrap();
assert_eq!(deserialized, proven_batch);
assert_eq!(deserialized.note_tree_root(), expected_root);

Ok(())
}

/// Notes with the same details but different metadata are not considered the same for batch
/// erasure.
#[test]
Expand Down
2 changes: 2 additions & 0 deletions crates/miden-tx-batch-prover/src/local_batch_prover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ impl LocalBatchProver {
input_notes,
output_notes,
batch_expiration_block_num,
batch_note_tree,
) = proposed_batch.into_parts();

ProvenBatch::new_unchecked(
Expand All @@ -40,6 +41,7 @@ impl LocalBatchProver {
updated_accounts,
input_notes,
output_notes,
batch_note_tree.root(),
batch_expiration_block_num,
tx_headers,
)
Expand Down
Loading