diff --git a/CHANGELOG.md b/CHANGELOG.md index 17ddc0485..0d1144cc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Added an endpoint to the `miden-proving-service` to update the workers (#1107). - Renamed the protobuf file of the transaction prover to `tx_prover.proto` (#1110). - [BREAKING] Renamed `AccountData` to `AccountFile` (#1116). +- Implement transaction batch prover in Rust (#1112). ## 0.7.2 (2025-01-28) - `miden-objects` crate only diff --git a/Cargo.lock b/Cargo.lock index 625e70ab0..e71b0dd5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2076,6 +2076,22 @@ dependencies = [ "winter-maybe-async", ] +[[package]] +name = "miden-tx-batch-prover" +version = "0.8.0" +dependencies = [ + "anyhow", + "miden-core", + "miden-crypto", + "miden-lib", + "miden-objects", + "miden-processor", + "miden-tx", + "rand", + "thiserror 2.0.11", + "winterfell", +] + [[package]] name = "miden-verifier" version = "0.12.0" @@ -5092,6 +5108,17 @@ dependencies = [ "winter-utils", ] +[[package]] +name = "winterfell" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6bdcd01333bbf4a349d8d13f269281524bd6d1a36ae3a853187f0665bf1cfd4" +dependencies = [ + "winter-air", + "winter-prover", + "winter-verifier", +] + [[package]] name = "write16" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index 6faffb034..1328ea6d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "bin/bench-tx", "bin/proving-service", + "crates/miden-tx-batch-prover", "crates/miden-lib", "crates/miden-objects", "crates/miden-proving-service-client", diff --git a/crates/miden-objects/src/batch/account_update.rs b/crates/miden-objects/src/batch/account_update.rs new file mode 100644 index 000000000..0dab76cd5 --- /dev/null +++ b/crates/miden-objects/src/batch/account_update.rs @@ -0,0 +1,161 @@ +use alloc::vec::Vec; + +use vm_core::utils::{ByteReader, ByteWriter, Deserializable, Serializable}; +use vm_processor::{DeserializationError, Digest}; + +use crate::{ + account::{delta::AccountUpdateDetails, AccountId}, + errors::BatchAccountUpdateError, + transaction::{ProvenTransaction, TransactionId}, +}; + +// BATCH ACCOUNT UPDATE +// ================================================================================================ + +/// Represents the changes made to an account resulting from executing a batch of transactions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BatchAccountUpdate { + /// ID of the updated account. + account_id: AccountId, + + /// Commitment to the state of the account before this update is applied. + /// + /// Equal to `Digest::default()` for new accounts. + initial_state_commitment: Digest, + + /// Commitment to the state of the account after this update is applied. + final_state_commitment: Digest, + + /// IDs of all transactions that updated the account. + transactions: Vec, + + /// A set of changes which can be applied to the previous account state (i.e. `initial_state`) + /// to get the new account state. For private accounts, this is set to + /// [`AccountUpdateDetails::Private`]. + details: AccountUpdateDetails, +} + +impl BatchAccountUpdate { + // CONSTRUCTORS + // -------------------------------------------------------------------------------------------- + + /// Creates a [`BatchAccountUpdate`] by cloning the update and other details from the provided + /// [`ProvenTransaction`]. + pub fn from_transaction(transaction: &ProvenTransaction) -> Self { + Self { + account_id: transaction.account_id(), + initial_state_commitment: transaction.account_update().init_state_hash(), + final_state_commitment: transaction.account_update().final_state_hash(), + transactions: vec![transaction.id()], + details: transaction.account_update().details().clone(), + } + } + + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the ID of the updated account. + pub fn account_id(&self) -> AccountId { + self.account_id + } + + /// Returns a commitment to the state of the account before this update is applied. + /// + /// This is equal to [`Digest::default()`] for new accounts. + pub fn initial_state_commitment(&self) -> Digest { + self.initial_state_commitment + } + + /// Returns a commitment to the state of the account after this update is applied. + pub fn final_state_commitment(&self) -> Digest { + self.final_state_commitment + } + + /// Returns a slice of [`TransactionId`]s that updated this account's state. + pub fn transactions(&self) -> &[TransactionId] { + &self.transactions + } + + /// Returns the contained [`AccountUpdateDetails`]. + /// + /// This update can be used to build the new account state from the previous account state. + pub fn details(&self) -> &AccountUpdateDetails { + &self.details + } + + /// Returns `true` if the account update details are for a private account. + pub fn is_private(&self) -> bool { + self.details.is_private() + } + + // MUTATORS + // -------------------------------------------------------------------------------------------- + + /// Merges the transaction's update into this account update. + /// + /// # Errors + /// + /// Returns an error if: + /// - The account ID of the merging transaction does not match the account ID of the existing + /// update. + /// - The merging transaction's initial state commitment does not match the final state + /// commitment of the current update. + /// - If the underlying [`AccountUpdateDetails::merge`] fails. + pub fn merge_proven_tx( + &mut self, + tx: &ProvenTransaction, + ) -> Result<(), BatchAccountUpdateError> { + if self.account_id != tx.account_id() { + return Err(BatchAccountUpdateError::AccountUpdateIdMismatch { + transaction: tx.id(), + expected_account_id: self.account_id, + actual_account_id: tx.account_id(), + }); + } + + if self.final_state_commitment != tx.account_update().init_state_hash() { + return Err(BatchAccountUpdateError::AccountUpdateInitialStateMismatch(tx.id())); + } + + self.details = self.details.clone().merge(tx.account_update().details().clone()).map_err( + |source_err| BatchAccountUpdateError::TransactionUpdateMergeError(tx.id(), source_err), + )?; + self.final_state_commitment = tx.account_update().final_state_hash(); + self.transactions.push(tx.id()); + + Ok(()) + } + + // CONVERSIONS + // -------------------------------------------------------------------------------------------- + + /// Consumes the update and returns the non-[`Copy`] parts. + pub fn into_parts(self) -> (Vec, AccountUpdateDetails) { + (self.transactions, self.details) + } +} + +// SERIALIZATION +// ================================================================================================ + +impl Serializable for BatchAccountUpdate { + fn write_into(&self, target: &mut W) { + self.account_id.write_into(target); + self.initial_state_commitment.write_into(target); + self.final_state_commitment.write_into(target); + self.transactions.write_into(target); + self.details.write_into(target); + } +} + +impl Deserializable for BatchAccountUpdate { + fn read_from(source: &mut R) -> Result { + Ok(Self { + account_id: AccountId::read_from(source)?, + initial_state_commitment: Digest::read_from(source)?, + final_state_commitment: Digest::read_from(source)?, + transactions: >::read_from(source)?, + details: AccountUpdateDetails::read_from(source)?, + }) + } +} diff --git a/crates/miden-objects/src/batch/batch_id.rs b/crates/miden-objects/src/batch/batch_id.rs new file mode 100644 index 000000000..ae7100eab --- /dev/null +++ b/crates/miden-objects/src/batch/batch_id.rs @@ -0,0 +1,65 @@ +use alloc::{string::String, vec::Vec}; + +use vm_core::{Felt, ZERO}; +use vm_processor::Digest; + +use crate::{ + account::AccountId, + transaction::{ProvenTransaction, TransactionId}, + Hasher, +}; + +// BATCH ID +// ================================================================================================ + +/// Uniquely identifies a batch of transactions, i.e. both +/// [`ProposedBatch`](crate::batch::ProposedBatch) and [`ProvenBatch`](crate::batch::ProvenBatch). +/// +/// This is a sequential hash of the tuple `(TRANSACTION_ID || [account_id_prefix, +/// account_id_suffix, 0, 0])` of all transactions and the accounts their executed against in the +/// batch. +#[derive(Debug, Copy, Clone, Eq, Ord, PartialEq, PartialOrd)] +pub struct BatchId(Digest); + +impl BatchId { + /// Calculates a batch ID from the given set of transactions. + pub fn from_transactions<'tx, T>(txs: T) -> Self + where + T: Iterator, + { + Self::from_ids(txs.map(|tx| (tx.id(), tx.account_id()))) + } + + /// Calculates a batch ID from the given transaction ID and account ID tuple. + pub fn from_ids(iter: impl Iterator) -> Self { + let mut elements: Vec = Vec::new(); + for (tx_id, account_id) in iter { + elements.extend_from_slice(tx_id.as_elements()); + let [account_id_prefix, account_id_suffix] = <[Felt; 2]>::from(account_id); + elements.extend_from_slice(&[account_id_prefix, account_id_suffix, ZERO, ZERO]); + } + + Self(Hasher::hash_elements(&elements)) + } + + /// Returns the elements representation of this batch ID. + pub fn as_elements(&self) -> &[Felt] { + self.0.as_elements() + } + + /// Returns the byte representation of this batch ID. + pub fn as_bytes(&self) -> [u8; 32] { + self.0.as_bytes() + } + + /// Returns a big-endian, hex-encoded string. + pub fn to_hex(&self) -> String { + self.0.to_hex() + } +} + +impl core::fmt::Display for BatchId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.to_hex()) + } +} diff --git a/crates/miden-objects/src/batch/mod.rs b/crates/miden-objects/src/batch/mod.rs index 23c43becc..dd0c0d433 100644 --- a/crates/miden-objects/src/batch/mod.rs +++ b/crates/miden-objects/src/batch/mod.rs @@ -1,2 +1,14 @@ mod note_tree; pub use note_tree::BatchNoteTree; + +mod batch_id; +pub use batch_id::BatchId; + +mod account_update; +pub use account_update::BatchAccountUpdate; + +mod proven_batch; +pub use proven_batch::ProvenBatch; + +mod proposed_batch; +pub use proposed_batch::ProposedBatch; diff --git a/crates/miden-objects/src/batch/note_tree.rs b/crates/miden-objects/src/batch/note_tree.rs index a0d0b5536..e6e98de31 100644 --- a/crates/miden-objects/src/batch/note_tree.rs +++ b/crates/miden-objects/src/batch/note_tree.rs @@ -35,4 +35,9 @@ impl BatchNoteTree { pub fn root(&self) -> RpoDigest { self.0.root() } + + /// Returns the number of non-empty leaves in this tree. + pub fn num_leaves(&self) -> usize { + self.0.num_leaves() + } } diff --git a/crates/miden-objects/src/batch/proposed_batch.rs b/crates/miden-objects/src/batch/proposed_batch.rs new file mode 100644 index 000000000..39aba4bd1 --- /dev/null +++ b/crates/miden-objects/src/batch/proposed_batch.rs @@ -0,0 +1,508 @@ +use alloc::{ + collections::{btree_map::Entry, BTreeMap, BTreeSet}, + sync::Arc, + vec::Vec, +}; + +use crate::{ + account::AccountId, + batch::{BatchAccountUpdate, BatchId, BatchNoteTree}, + block::{BlockHeader, BlockNumber}, + errors::ProposedBatchError, + note::{NoteHeader, NoteId, NoteInclusionProof}, + transaction::{ + ChainMmr, InputNoteCommitment, InputNotes, OutputNote, ProvenTransaction, TransactionId, + }, + MAX_ACCOUNTS_PER_BATCH, MAX_INPUT_NOTES_PER_BATCH, MAX_OUTPUT_NOTES_PER_BATCH, +}; + +/// A proposed batch of transactions with all necessary data to validate it. +/// +/// See [`ProposedBatch::new`] for what a proposed batch expects and guarantees. +/// +/// This type is fairly large, so consider boxing it. +#[derive(Debug, Clone)] +pub struct ProposedBatch { + /// The transactions of this batch. + transactions: Vec>, + /// The header is boxed as it has a large stack size. + block_header: BlockHeader, + /// The chain MMR used to authenticate: + /// - all unauthenticated notes that can be authenticated, + /// - all block hashes referenced by the transactions in the batch. + chain_mmr: ChainMmr, + /// The note inclusion proofs for unauthenticated notes that were consumed in the batch which + /// can be authenticated. + unauthenticated_note_proofs: BTreeMap, + /// The ID of the batch, which is a cryptographic commitment to the transactions in the batch. + id: BatchId, + /// A map from account ID's updated in this batch to the aggregated update from all + /// transaction's that touched the account. + account_updates: BTreeMap, + /// The block number at which the batch will expire. This is the minimum of all transaction's + /// expiration block number. + batch_expiration_block_num: BlockNumber, + /// The input note commitment of the transaction batch. This consists of all authenticated + /// notes that transactions in the batch consume as well as unauthenticated notes whose + /// authentication is delayed to the block kernel. + input_notes: InputNotes, + /// The SMT over the output notes of this batch. + output_notes_tree: BatchNoteTree, + /// The output notes of this batch. This consists of all notes created by transactions in the + /// batch that are not consumed within the same batch. + output_notes: Vec, +} + +impl ProposedBatch { + // CONSTRUCTORS + // -------------------------------------------------------------------------------------------- + + /// Creates a new [`ProposedBatch`] from the provided parts. + /// + /// # Inputs + /// + /// - The given transactions must be correctly ordered. That is, if two transactions A and B + /// update the same account in this order, meaning A's initial account state commitment + /// matches the account state before any transactions are executed and B's initial account + /// state commitment matches the final account state commitment of A, then A must come before + /// B. + /// - The chain MMR should contain all block headers + /// - that are referenced by note inclusion proofs in `unauthenticated_note_proofs`. + /// - that are referenced by a transaction in the batch. + /// - The `unauthenticated_note_proofs` should contain [`NoteInclusionProof`]s for any + /// unauthenticated note consumed by the transaction's in the batch which can be + /// authenticated. This means it is not required that every unauthenticated note has an entry + /// in this map for two reasons. + /// - Unauthenticated note authentication can be delayed to the block kernel. + /// - Another transaction in the batch creates an output note matching an unauthenticated + /// input note, in which case inclusion in the chain does not need to be proven. + /// - The block header's block number must be greater or equal to the highest block number + /// referenced by any transaction. This is not verified explicitly, but will implicitly cause + /// an error during validating that each reference block of a transaction is in the chain MMR. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// - The number of input notes exceeds [`MAX_INPUT_NOTES_PER_BATCH`]. + /// - Note that unauthenticated notes that are created in the same batch do not count. Any + /// other input notes, unauthenticated or not, do count. + /// - The number of output notes exceeds [`MAX_OUTPUT_NOTES_PER_BATCH`]. + /// - Note that output notes that are consumed in the same batch as unauthenticated input + /// notes do not count. + /// - Any note is consumed more than once. + /// - Any note is created more than once. + /// - The number of account updates exceeds [`MAX_ACCOUNTS_PER_BATCH`]. + /// - Note that any number of transactions against the same account count as one update. + /// - The chain MMRs chain length does not match the block header's block number. This means the + /// chain MMR should not contain the block header itself as it is added to the MMR in the + /// batch kernel. + /// - The chain MMRs hashed peaks do not match the block header's chain root. + /// - The reference block of any transaction is not in the chain MMR. + /// - The note inclusion proof for an unauthenticated note fails to verify. + /// - The block referenced by a note inclusion proof for an unauthenticated note is missing from + /// the chain MMR. + /// - The transactions in the proposed batch which update the same account are not correctly + /// ordered. + /// - The provided list of transactions is empty. An empty batch is pointless and would + /// potentially result in the same [`BatchId`] for two empty batches which would mean batch + /// IDs are no longer unique. + /// - There are duplicate transactions. + pub fn new( + transactions: Vec>, + block_header: BlockHeader, + chain_mmr: ChainMmr, + unauthenticated_note_proofs: BTreeMap, + ) -> Result { + // Check for empty or duplicate transactions. + // -------------------------------------------------------------------------------------------- + + if transactions.is_empty() { + return Err(ProposedBatchError::EmptyTransactionBatch); + } + + let mut transaction_set = BTreeSet::new(); + for tx in transactions.iter() { + if !transaction_set.insert(tx.id()) { + return Err(ProposedBatchError::DuplicateTransaction { transaction_id: tx.id() }); + } + } + + // Verify block header and chain MMR match. + // -------------------------------------------------------------------------------------------- + + if chain_mmr.chain_length() != block_header.block_num() { + return Err(ProposedBatchError::InconsistentChainLength { + expected: block_header.block_num(), + actual: chain_mmr.chain_length(), + }); + } + + let hashed_peaks = chain_mmr.peaks().hash_peaks(); + if hashed_peaks != block_header.chain_root() { + return Err(ProposedBatchError::InconsistentChainRoot { + expected: block_header.chain_root(), + actual: hashed_peaks, + }); + } + + // Verify all block references from the transactions are in the chain. + // -------------------------------------------------------------------------------------------- + + // Aggregate block references into a set since the chain MMR does not index by hash. + let mut block_references = + BTreeSet::from_iter(chain_mmr.block_headers().map(BlockHeader::hash)); + // Insert the block referenced by the batch to consider it authenticated. We can assume this + // because the block kernel will verify the block hash as it is a public input to the batch + // kernel. + block_references.insert(block_header.hash()); + + for tx in transactions.iter() { + if !block_references.contains(&tx.block_ref()) { + return Err(ProposedBatchError::MissingTransactionBlockReference { + block_reference: tx.block_ref(), + transaction_id: tx.id(), + }); + } + } + + // Aggregate individual tx-level account updates into a batch-level account update - one per + // account. + // -------------------------------------------------------------------------------------------- + + // Populate batch output notes and updated accounts. + let mut account_updates = BTreeMap::::new(); + let mut batch_expiration_block_num = BlockNumber::from(u32::MAX); + for tx in transactions.iter() { + // Merge account updates so that state transitions A->B->C become A->C. + match account_updates.entry(tx.account_id()) { + Entry::Vacant(vacant) => { + let batch_account_update = BatchAccountUpdate::from_transaction(tx); + vacant.insert(batch_account_update); + }, + Entry::Occupied(occupied) => { + // This returns an error if the transactions are not correctly ordered, e.g. if + // B comes before A. + occupied.into_mut().merge_proven_tx(tx).map_err(|source| { + ProposedBatchError::AccountUpdateError { + account_id: tx.account_id(), + source, + } + })?; + }, + }; + + // The expiration block of the batch is the minimum of all transaction's expiration + // block. + batch_expiration_block_num = batch_expiration_block_num.min(tx.expiration_block_num()); + } + + if account_updates.len() > MAX_ACCOUNTS_PER_BATCH { + return Err(ProposedBatchError::TooManyAccountUpdates(account_updates.len())); + } + + // Check for duplicates in input notes. + // -------------------------------------------------------------------------------------------- + + // Check for duplicate input notes both within a transaction and across transactions. + // This also includes authenticated notes, as the transaction kernel doesn't check for + // duplicates. + let mut input_note_map = BTreeMap::new(); + + for tx in transactions.iter() { + for note in tx.input_notes() { + let nullifier = note.nullifier(); + if let Some(first_transaction_id) = input_note_map.insert(nullifier, tx.id()) { + return Err(ProposedBatchError::DuplicateInputNote { + note_nullifier: nullifier, + first_transaction_id, + second_transaction_id: tx.id(), + }); + } + } + } + + // Create input and output note set of the batch. + // -------------------------------------------------------------------------------------------- + + // Check for duplicate output notes and remove all output notes from the batch output note + // set that are consumed by transactions. + let mut output_notes = BatchOutputNoteTracker::new(transactions.iter().map(AsRef::as_ref))?; + let mut input_notes = vec![]; + + for tx in transactions.iter() { + for input_note in tx.input_notes().iter() { + // Header is present only for unauthenticated input notes. + let input_note = match input_note.header() { + Some(input_note_header) => { + if output_notes.remove_note(input_note_header)? { + // If a transaction consumes an unauthenticated note that is also + // created in this batch, it is removed from the set of output notes. + // We `continue` so that the input note is not added to the set of input + // notes of the batch. That way the note appears in neither input nor + // output set. + continue; + } + + // If an inclusion proof for an unauthenticated note is provided and the + // proof is valid, it means the note is part of the chain and we can mark it + // as authenticated by erasing the note header. + if let Some(proof) = + unauthenticated_note_proofs.get(&input_note_header.id()) + { + let note_block_header = chain_mmr + .get_block(proof.location().block_num()) + .ok_or_else(|| { + ProposedBatchError::UnauthenticatedInputNoteBlockNotInChainMmr { + block_number: proof.location().block_num(), + note_id: input_note_header.id(), + } + })?; + + authenticate_unauthenticated_note( + input_note_header, + proof, + note_block_header, + )?; + + // Erase the note header from the input note. + InputNoteCommitment::from(input_note.nullifier()) + } else { + input_note.clone() + } + }, + None => input_note.clone(), + }; + input_notes.push(input_note); + } + } + + let output_notes = output_notes.into_notes(); + + if input_notes.len() > MAX_INPUT_NOTES_PER_BATCH { + return Err(ProposedBatchError::TooManyInputNotes(input_notes.len())); + } + // SAFETY: This is safe as we have checked for duplicates and the max number of input notes + // in a batch. + let input_notes = InputNotes::new_unchecked(input_notes); + + if output_notes.len() > MAX_OUTPUT_NOTES_PER_BATCH { + return Err(ProposedBatchError::TooManyOutputNotes(output_notes.len())); + } + + // Build the output notes SMT. + // -------------------------------------------------------------------------------------------- + + // SAFETY: We can `expect` here because: + // - the batch output note tracker already returns an error for duplicate output notes, + // - we have checked that the number of output notes is <= 2^BATCH_NOTE_TREE_DEPTH. + let output_notes_tree = BatchNoteTree::with_contiguous_leaves( + output_notes.iter().map(|note| (note.id(), note.metadata())), + ) + .expect("there should be no duplicate notes and there should be <= 2^BATCH_NOTE_TREE_DEPTH notes"); + + // Compute batch ID. + // -------------------------------------------------------------------------------------------- + + let id = BatchId::from_transactions(transactions.iter().map(AsRef::as_ref)); + + Ok(Self { + id, + transactions, + block_header, + chain_mmr, + unauthenticated_note_proofs, + account_updates, + batch_expiration_block_num, + input_notes, + output_notes, + output_notes_tree, + }) + } + + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns a slice of the [`ProvenTransaction`]s in the batch. + pub fn transactions(&self) -> &[Arc] { + &self.transactions + } + + /// Returns the map of account IDs mapped to their [`BatchAccountUpdate`]s. + /// + /// If an account was updated by multiple transactions, the [`BatchAccountUpdate`] is the result + /// of merging the individual updates. + /// + /// For example, suppose an account's state before this batch is `A` and the batch contains two + /// transactions that updated it. Applying the first transaction results in intermediate state + /// `B`, and applying the second one results in state `C`. Then the returned update represents + /// the state transition from `A` to `C`. + pub fn account_updates(&self) -> &BTreeMap { + &self.account_updates + } + + /// The ID of this batch. See [`BatchId`] for details on how it is computed. + pub fn id(&self) -> BatchId { + self.id + } + + /// Returns the block number at which the batch will expire. + pub fn batch_expiration_block_num(&self) -> BlockNumber { + self.batch_expiration_block_num + } + + /// Returns the [`InputNotes`] of this batch. + pub fn input_notes(&self) -> &InputNotes { + &self.input_notes + } + + /// Returns the output notes of the batch. + /// + /// This is the aggregation of all output notes by the transactions in the batch, except the + /// ones that were consumed within the batch itself. + pub fn output_notes(&self) -> &[OutputNote] { + &self.output_notes + } + + /// Returns the [`BatchNoteTree`] representing the output notes of the batch. + pub fn output_notes_tree(&self) -> &BatchNoteTree { + &self.output_notes_tree + } + + /// Consumes the proposed batch and returns its underlying parts. + #[allow(clippy::type_complexity)] + pub fn into_parts( + self, + ) -> ( + Vec>, + BlockHeader, + ChainMmr, + BTreeMap, + BatchId, + BTreeMap, + InputNotes, + BatchNoteTree, + Vec, + BlockNumber, + ) { + ( + self.transactions, + self.block_header, + self.chain_mmr, + self.unauthenticated_note_proofs, + self.id, + self.account_updates, + self.input_notes, + self.output_notes_tree, + self.output_notes, + self.batch_expiration_block_num, + ) + } +} + +// BATCH OUTPUT NOTE TRACKER +// ================================================================================================ + +/// A helper struct to track output notes. +/// Its main purpose is to check for duplicates and allow for removal of output notes that are +/// consumed in the same batch, so are not output notes of the batch. +/// +/// The approach for this is that the output note set is initialized to the union of all output +/// notes of the transactions in the batch. +/// Then (outside of this struct) all input notes of transactions in the batch which are also output +/// notes can be removed, as they are considered consumed within the batch and will not be visible +/// as created or consumed notes for the batch. +#[derive(Debug)] +struct BatchOutputNoteTracker { + /// An index from [`NoteId`]s to the transaction that creates the note and the note itself. + /// The transaction ID is tracked to produce better errors when a duplicate note is + /// encountered. + output_notes: BTreeMap, +} + +impl BatchOutputNoteTracker { + /// Constructs a new output note tracker from the given transactions. + /// + /// # Errors + /// + /// Returns an error if: + /// - any output note is created more than once (by the same or different transactions). + fn new<'a>( + txs: impl Iterator, + ) -> Result { + let mut output_notes = BTreeMap::new(); + for tx in txs { + for note in tx.output_notes().iter() { + if let Some((first_transaction_id, _)) = + output_notes.insert(note.id(), (tx.id(), note.clone())) + { + return Err(ProposedBatchError::DuplicateOutputNote { + note_id: note.id(), + first_transaction_id, + second_transaction_id: tx.id(), + }); + } + } + } + + Ok(Self { output_notes }) + } + + /// Attempts to remove the given input note from the output note set. + /// + /// Returns `true` if the given note existed in the output note set and was removed from it, + /// `false` otherwise. + /// + /// # Errors + /// + /// Returns an error if: + /// - the given note has a corresponding note in the output note set with the same [`NoteId`] + /// but their hashes differ (i.e. their metadata is different). + pub fn remove_note( + &mut self, + input_note_header: &NoteHeader, + ) -> Result { + let id = input_note_header.id(); + if let Some((_, output_note)) = self.output_notes.remove(&id) { + // Check if the notes with the same ID have differing hashes. + // This could happen if the metadata of the notes is different, which we consider an + // error. + let input_hash = input_note_header.hash(); + let output_hash = output_note.hash(); + if output_hash != input_hash { + return Err(ProposedBatchError::NoteHashesMismatch { id, input_hash, output_hash }); + } + + return Ok(true); + } + + Ok(false) + } + + /// Consumes the tracker and returns a [`Vec`] of output notes sorted by [`NoteId`]. + pub fn into_notes(self) -> Vec { + self.output_notes.into_iter().map(|(_, (_, output_note))| output_note).collect() + } +} + +// HELPER FUNCTIONS +// ================================================================================================ + +/// Validates whether the provided header of an unauthenticated note belongs to the note tree of the +/// specified block header. +fn authenticate_unauthenticated_note( + note_header: &NoteHeader, + proof: &NoteInclusionProof, + block_header: &BlockHeader, +) -> Result<(), ProposedBatchError> { + let note_index = proof.location().node_index_in_block().into(); + let note_hash = note_header.hash(); + proof + .note_path() + .verify(note_index, note_hash, &block_header.note_root()) + .map_err(|source| ProposedBatchError::UnauthenticatedNoteAuthenticationFailed { + note_id: note_header.id(), + block_num: proof.location().block_num(), + source, + }) +} diff --git a/crates/miden-objects/src/batch/proven_batch.rs b/crates/miden-objects/src/batch/proven_batch.rs new file mode 100644 index 000000000..162247100 --- /dev/null +++ b/crates/miden-objects/src/batch/proven_batch.rs @@ -0,0 +1,93 @@ +use alloc::{collections::BTreeMap, vec::Vec}; + +use crate::{ + account::AccountId, + batch::{BatchAccountUpdate, BatchId, BatchNoteTree}, + block::BlockNumber, + note::Nullifier, + transaction::{InputNoteCommitment, InputNotes, OutputNote}, +}; + +/// A transaction batch with an execution proof. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProvenBatch { + id: BatchId, + account_updates: BTreeMap, + input_notes: InputNotes, + output_notes_smt: BatchNoteTree, + output_notes: Vec, + batch_expiration_block_num: BlockNumber, +} + +impl ProvenBatch { + // CONSTRUCTORS + // -------------------------------------------------------------------------------------------- + + /// Creates a new [`ProvenBatch`] from the provided parts. + pub fn new( + id: BatchId, + account_updates: BTreeMap, + input_notes: InputNotes, + output_notes_smt: BatchNoteTree, + output_notes: Vec, + batch_expiration_block_num: BlockNumber, + ) -> Self { + Self { + id, + account_updates, + input_notes, + output_notes_smt, + output_notes, + batch_expiration_block_num, + } + } + + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// The ID of this batch. See [`BatchId`] for details on how it is computed. + pub fn id(&self) -> BatchId { + self.id + } + + /// Returns the block number at which the batch will expire. + pub fn batch_expiration_block_num(&self) -> BlockNumber { + self.batch_expiration_block_num + } + + /// Returns the map of account IDs mapped to their [`BatchAccountUpdate`]s. + /// + /// If an account was updated by multiple transactions, the [`BatchAccountUpdate`] is the result + /// of merging the individual updates. + /// + /// For example, suppose an account's state before this batch is `A` and the batch contains two + /// transactions that updated it. Applying the first transaction results in intermediate state + /// `B`, and applying the second one results in state `C`. Then the returned update represents + /// the state transition from `A` to `C`. + pub fn account_updates(&self) -> &BTreeMap { + &self.account_updates + } + + /// Returns the [`InputNotes`] of this batch. + pub fn input_notes(&self) -> &InputNotes { + &self.input_notes + } + + /// Returns an iterator over the nullifiers produced in this batch. + pub fn produced_nullifiers(&self) -> impl Iterator + use<'_> { + self.input_notes.iter().map(InputNoteCommitment::nullifier) + } + + /// Returns the output notes of the batch. + /// + /// This is the aggregation of all output notes by the transactions in the batch, except the + /// ones that were consumed within the batch itself. + pub fn output_notes(&self) -> &[OutputNote] { + &self.output_notes + } + + /// Returns the [`BatchNoteTree`] representing the output notes of the batch. + pub fn output_notes_tree(&self) -> &BatchNoteTree { + &self.output_notes_smt + } +} diff --git a/crates/miden-objects/src/block/block_number.rs b/crates/miden-objects/src/block/block_number.rs index 851ed4f00..656af8049 100644 --- a/crates/miden-objects/src/block/block_number.rs +++ b/crates/miden-objects/src/block/block_number.rs @@ -40,11 +40,6 @@ impl BlockNumber { BlockNumber((epoch as u32) << BlockNumber::EPOCH_LENGTH_EXPONENT) } - /// Creates a `BlockNumber` from a `usize`. - pub fn from_usize(value: usize) -> Self { - BlockNumber(value as u32) - } - /// Returns the epoch to which this block number belongs. pub const fn block_epoch(&self) -> u16 { (self.0 >> BlockNumber::EPOCH_LENGTH_EXPONENT) as u16 diff --git a/crates/miden-objects/src/block/mod.rs b/crates/miden-objects/src/block/mod.rs index 500c24a0e..f80cede62 100644 --- a/crates/miden-objects/src/block/mod.rs +++ b/crates/miden-objects/src/block/mod.rs @@ -7,8 +7,10 @@ use super::{ mod header; pub use header::BlockHeader; + mod block_number; pub use block_number::BlockNumber; + mod note_tree; pub use note_tree::{BlockNoteIndex, BlockNoteTree}; @@ -231,8 +233,8 @@ pub fn compute_tx_hash( ) -> Digest { let mut elements = vec![]; for (transaction_id, account_id) in updated_accounts { - let account_id_felts: [Felt; 2] = account_id.into(); - elements.extend_from_slice(&[account_id_felts[0], account_id_felts[1], ZERO, ZERO]); + let [account_id_prefix, account_id_suffix] = <[Felt; 2]>::from(account_id); + elements.extend_from_slice(&[account_id_prefix, account_id_suffix, ZERO, ZERO]); elements.extend_from_slice(transaction_id.as_elements()); } diff --git a/crates/miden-objects/src/errors.rs b/crates/miden-objects/src/errors.rs index 38da1fa1d..a47752c69 100644 --- a/crates/miden-objects/src/errors.rs +++ b/crates/miden-objects/src/errors.rs @@ -22,7 +22,9 @@ use crate::{ }, block::BlockNumber, note::{NoteAssets, NoteExecutionHint, NoteTag, NoteType, Nullifier}, - ACCOUNT_UPDATE_MAX_SIZE, MAX_INPUTS_PER_NOTE, MAX_INPUT_NOTES_PER_TX, MAX_OUTPUT_NOTES_PER_TX, + transaction::TransactionId, + ACCOUNT_UPDATE_MAX_SIZE, MAX_ACCOUNTS_PER_BATCH, MAX_INPUTS_PER_NOTE, + MAX_INPUT_NOTES_PER_BATCH, MAX_INPUT_NOTES_PER_TX, MAX_OUTPUT_NOTES_PER_TX, }; // ACCOUNT COMPONENT TEMPLATE ERROR @@ -182,6 +184,23 @@ pub enum AccountDeltaError { NotAFungibleFaucetId(AccountId), } +// BATCH ACCOUNT UPDATE ERROR +// ================================================================================================ + +#[derive(Debug, Error)] +pub enum BatchAccountUpdateError { + #[error("account update for account {expected_account_id} cannot be merged with update from transaction {transaction} which was executed against account {actual_account_id}")] + AccountUpdateIdMismatch { + transaction: TransactionId, + expected_account_id: AccountId, + actual_account_id: AccountId, + }, + #[error("final state commitment in account update from transaction {0} does not match initial state of current update")] + AccountUpdateInitialStateMismatch(TransactionId), + #[error("failed to merge account delta from transaction {0}")] + TransactionUpdateMergeError(TransactionId, #[source] AccountDeltaError), +} + // ASSET ERROR // ================================================================================================ @@ -429,6 +448,90 @@ pub enum ProvenTransactionError { }, } +// BATCH ERROR +// ================================================================================================ + +#[derive(Debug, Error)] +pub enum ProposedBatchError { + #[error( + "transaction batch has {0} input notes but at most {MAX_INPUT_NOTES_PER_BATCH} are allowed" + )] + TooManyInputNotes(usize), + + #[error( + "transaction batch has {0} output notes but at most {MAX_OUTPUT_NOTES_PER_BATCH} are allowed" + )] + TooManyOutputNotes(usize), + + #[error( + "transaction batch has {0} account updates but at most {MAX_ACCOUNTS_PER_BATCH} are allowed" + )] + TooManyAccountUpdates(usize), + + #[error("transaction batch must contain at least one transaction")] + EmptyTransactionBatch, + + #[error("transaction {transaction_id} appears twice in the proposed batch input")] + DuplicateTransaction { transaction_id: TransactionId }, + + #[error("transaction {second_transaction_id} consumes the note with nullifier {note_nullifier} that is also consumed by another transaction {first_transaction_id} in the batch")] + DuplicateInputNote { + note_nullifier: Nullifier, + first_transaction_id: TransactionId, + second_transaction_id: TransactionId, + }, + + #[error("transaction {second_transaction_id} creates the note with id {note_id} that is also created by another transaction {first_transaction_id} in the batch")] + DuplicateOutputNote { + note_id: NoteId, + first_transaction_id: TransactionId, + second_transaction_id: TransactionId, + }, + + #[error("note hashes mismatch for note {id}: (input: {input_hash}, output: {output_hash})")] + NoteHashesMismatch { + id: NoteId, + input_hash: Digest, + output_hash: Digest, + }, + + #[error("failed to merge transaction delta into account {account_id}")] + AccountUpdateError { + account_id: AccountId, + source: BatchAccountUpdateError, + }, + + #[error("unable to prove unauthenticated note inclusion because block {block_number} in which note with id {note_id} was created is not in chain mmr")] + UnauthenticatedInputNoteBlockNotInChainMmr { + block_number: BlockNumber, + note_id: NoteId, + }, + + #[error( + "unable to prove unauthenticated note inclusion of note {note_id} in block {block_num}" + )] + UnauthenticatedNoteAuthenticationFailed { + note_id: NoteId, + block_num: BlockNumber, + source: MerkleError, + }, + + #[error("chain mmr has length {actual} which does not match block number {expected} ")] + InconsistentChainLength { + expected: BlockNumber, + actual: BlockNumber, + }, + + #[error("chain mmr has root {actual} which does not match block header's root {expected}")] + InconsistentChainRoot { expected: Digest, actual: Digest }, + + #[error("block {block_reference} referenced by transaction {transaction_id} is not in the chain mmr")] + MissingTransactionBlockReference { + block_reference: Digest, + transaction_id: TransactionId, + }, +} + // BLOCK VALIDATION ERROR // ================================================================================================ diff --git a/crates/miden-objects/src/lib.rs b/crates/miden-objects/src/lib.rs index 069a43230..c0753ca92 100644 --- a/crates/miden-objects/src/lib.rs +++ b/crates/miden-objects/src/lib.rs @@ -24,9 +24,9 @@ mod errors; pub use constants::*; pub use errors::{ - AccountDeltaError, AccountError, AccountIdError, AssetError, AssetVaultError, BlockError, - ChainMmrError, NoteError, ProvenTransactionError, TransactionInputError, - TransactionOutputError, TransactionScriptError, + AccountDeltaError, AccountError, AccountIdError, AssetError, AssetVaultError, + BatchAccountUpdateError, BlockError, ChainMmrError, NoteError, ProposedBatchError, + ProvenTransactionError, TransactionInputError, TransactionOutputError, TransactionScriptError, }; pub use miden_crypto::hash::rpo::{Rpo256 as Hasher, RpoDigest as Digest}; pub use vm_core::{Felt, FieldElement, StarkField, Word, EMPTY_WORD, ONE, WORD_SIZE, ZERO}; diff --git a/crates/miden-objects/src/testing/chain_mmr.rs b/crates/miden-objects/src/testing/chain_mmr.rs new file mode 100644 index 000000000..833d7fe8e --- /dev/null +++ b/crates/miden-objects/src/testing/chain_mmr.rs @@ -0,0 +1,46 @@ +use miden_crypto::merkle::{Mmr, PartialMmr}; + +use crate::{block::BlockHeader, transaction::ChainMmr, ChainMmrError}; + +impl ChainMmr { + /// Converts the [`Mmr`] into a [`ChainMmr`] by selectively copying all leaves that are in the + /// given `blocks` iterator. + /// + /// This tracks all blocks in the given iterator in the [`ChainMmr`] except for the block whose + /// block number equals [`Mmr::forest`], which is the current chain length. + /// + /// # Panics + /// + /// Due to being only available in test scenarios, this function panics when one of the given + /// blocks does not exist in the provided mmr. + pub fn from_mmr( + mmr: &Mmr, + blocks: impl IntoIterator + Clone, + ) -> Result + where + I: Iterator, + { + // We do not include the latest block as it is used as the reference block and is added to + // the MMR by the transaction or batch kernel. + + let target_forest = mmr.forest() - 1; + let peaks = mmr + .peaks_at(target_forest) + .expect("target_forest should be smaller than forest of the mmr"); + let mut partial_mmr = PartialMmr::from_peaks(peaks); + + for block_num in blocks + .clone() + .into_iter() + .map(|header| header.block_num().as_usize()) + .filter(|block_num| *block_num < target_forest) + { + let leaf = mmr.get(block_num).expect("error: block num does not exist"); + let path = + mmr.open_at(block_num, target_forest).expect("error: block proof").merkle_path; + partial_mmr.track(block_num, leaf, &path).expect("error: partial mmr track"); + } + + ChainMmr::new(partial_mmr, blocks) + } +} diff --git a/crates/miden-objects/src/testing/mod.rs b/crates/miden-objects/src/testing/mod.rs index d84a91f76..1e58e5508 100644 --- a/crates/miden-objects/src/testing/mod.rs +++ b/crates/miden-objects/src/testing/mod.rs @@ -11,6 +11,7 @@ pub mod account_component; pub mod account_id; pub mod asset; pub mod block; +pub mod chain_mmr; pub mod constants; pub mod note; pub mod storage; diff --git a/crates/miden-objects/src/transaction/chain_mmr.rs b/crates/miden-objects/src/transaction/chain_mmr.rs index 431193d3b..764a1c42a 100644 --- a/crates/miden-objects/src/transaction/chain_mmr.rs +++ b/crates/miden-objects/src/transaction/chain_mmr.rs @@ -1,4 +1,4 @@ -use alloc::{collections::BTreeMap, vec::Vec}; +use alloc::collections::BTreeMap; use vm_core::utils::{Deserializable, Serializable}; @@ -43,11 +43,13 @@ impl ChainMmr { /// partial MMR. /// - The same block appears more than once in the provided list of block headers. /// - The partial MMR does not track authentication paths for any of the specified blocks. - pub fn new(mmr: PartialMmr, blocks: Vec) -> Result { + pub fn new( + mmr: PartialMmr, + blocks: impl IntoIterator, + ) -> Result { let chain_length = mmr.forest(); - let mut block_map = BTreeMap::new(); - for block in blocks.into_iter() { + for block in blocks { if block.block_num().as_usize() >= chain_length { return Err(ChainMmrError::block_num_too_big(chain_length, block.block_num())); } @@ -67,6 +69,11 @@ impl ChainMmr { // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- + /// Returns the underlying [`PartialMmr`]. + pub fn mmr(&self) -> &PartialMmr { + &self.mmr + } + /// Returns peaks of this MMR. pub fn peaks(&self) -> MmrPeaks { self.mmr.peaks() @@ -91,6 +98,11 @@ impl ChainMmr { self.blocks.get(&block_num) } + /// Returns an iterator over the block headers in this chain MMR. + pub fn block_headers(&self) -> impl Iterator { + self.blocks.values() + } + // DATA MUTATORS // -------------------------------------------------------------------------------------------- diff --git a/crates/miden-objects/src/transaction/inputs.rs b/crates/miden-objects/src/transaction/inputs.rs index 93f9dda5c..d4a770101 100644 --- a/crates/miden-objects/src/transaction/inputs.rs +++ b/crates/miden-objects/src/transaction/inputs.rs @@ -206,6 +206,19 @@ impl InputNotes { Ok(Self { notes, commitment }) } + /// Returns new [`InputNotes`] instantiated from the provided vector of notes without checking + /// their validity. + /// + /// This is exposed for use in transaction batches, but should generally not be used. + /// + /// # Warning + /// + /// This does not run the checks from [`InputNotes::new`], so the latter should be preferred. + pub fn new_unchecked(notes: Vec) -> Self { + let commitment = build_input_note_commitment(¬es); + Self { notes, commitment } + } + // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- diff --git a/crates/miden-objects/src/transaction/proven_tx.rs b/crates/miden-objects/src/transaction/proven_tx.rs index 55989b985..76ea35c98 100644 --- a/crates/miden-objects/src/transaction/proven_tx.rs +++ b/crates/miden-objects/src/transaction/proven_tx.rs @@ -34,6 +34,12 @@ pub struct ProvenTransaction { /// while for public notes this will also contain full note details. output_notes: OutputNotes, + /// [`BlockNumber`] of the transaction's reference block. + /// + /// This is not needed for proving the transaction, but it is useful for the node to lookup the + /// block. + block_num: BlockNumber, + /// The block hash of the last known block at the time the transaction was executed. block_ref: Digest, @@ -75,6 +81,11 @@ impl ProvenTransaction { &self.proof } + /// Returns the number of the reference block the transaction was executed against. + pub fn block_num(&self) -> BlockNumber { + self.block_num + } + /// Returns the block reference the transaction was executed against. pub fn block_ref(&self) -> Digest { self.block_ref @@ -153,6 +164,7 @@ impl Serializable for ProvenTransaction { self.account_update.write_into(target); self.input_notes.write_into(target); self.output_notes.write_into(target); + self.block_num.write_into(target); self.block_ref.write_into(target); self.expiration_block_num.write_into(target); self.proof.write_into(target); @@ -166,6 +178,7 @@ impl Deserializable for ProvenTransaction { let input_notes = >::read_from(source)?; let output_notes = OutputNotes::read_from(source)?; + let block_num = BlockNumber::read_from(source)?; let block_ref = Digest::read_from(source)?; let expiration_block_num = BlockNumber::read_from(source)?; let proof = ExecutionProof::read_from(source)?; @@ -182,6 +195,7 @@ impl Deserializable for ProvenTransaction { account_update, input_notes, output_notes, + block_num, block_ref, expiration_block_num, proof, @@ -217,6 +231,9 @@ pub struct ProvenTransactionBuilder { /// List of [OutputNote]s of all notes created by the transaction. output_notes: Vec, + /// [`BlockNumber`] of the transaction's reference block. + block_num: BlockNumber, + /// Block [Digest] of the transaction's reference block. block_ref: Digest, @@ -236,6 +253,7 @@ impl ProvenTransactionBuilder { account_id: AccountId, initial_account_hash: Digest, final_account_hash: Digest, + block_num: BlockNumber, block_ref: Digest, expiration_block_num: BlockNumber, proof: ExecutionProof, @@ -247,6 +265,7 @@ impl ProvenTransactionBuilder { account_update_details: AccountUpdateDetails::Private, input_notes: Vec::new(), output_notes: Vec::new(), + block_num, block_ref, expiration_block_num, proof, @@ -310,6 +329,7 @@ impl ProvenTransactionBuilder { account_update, input_notes, output_notes, + block_num: self.block_num, block_ref: self.block_ref, expiration_block_num: self.expiration_block_num, proof: self.proof, diff --git a/crates/miden-tx-batch-prover/Cargo.toml b/crates/miden-tx-batch-prover/Cargo.toml new file mode 100644 index 000000000..240db4fce --- /dev/null +++ b/crates/miden-tx-batch-prover/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "miden-tx-batch-prover" +version = "0.8.0" +description = "Miden rollup transaction batch executor and prover" +readme = "README.md" +categories = ["no-std"] +keywords = ["miden", "batch", "prover"] +license.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +rust-version.workspace = true +edition.workspace = true + +[lib] +bench = false + +[features] +default = ["std"] +std = [ + "miden-objects/std", + "miden-tx/std", + "miden-crypto/std", + "vm-core/std", + "vm-processor/std", +] + +[dependencies] +miden-crypto = { workspace = true } +miden-tx = { workspace = true } +miden-objects = { workspace = true } +thiserror = { workspace = true } +vm-core = { workspace = true } +vm-processor = { workspace = true } + +[dev-dependencies] +anyhow = { version = "1.0", features = ["std", "backtrace"] } +miden-lib = { workspace = true, features = ["std", "testing"] } +miden-tx = { workspace = true, features = ["std", "testing"] } +rand = { workspace = true, features = ["small_rng"] } +winterfell = { version = "0.11" } diff --git a/crates/miden-tx-batch-prover/README.md b/crates/miden-tx-batch-prover/README.md new file mode 100644 index 000000000..85d4babb7 --- /dev/null +++ b/crates/miden-tx-batch-prover/README.md @@ -0,0 +1,7 @@ +# Miden Transaction Batch Prover + +This crate contains tools for executing and proving Miden transaction batches. + +## License + +This project is [MIT licensed](../LICENSE). diff --git a/crates/miden-tx-batch-prover/src/errors.rs b/crates/miden-tx-batch-prover/src/errors.rs new file mode 100644 index 000000000..e0500f239 --- /dev/null +++ b/crates/miden-tx-batch-prover/src/errors.rs @@ -0,0 +1,12 @@ +use miden_objects::transaction::TransactionId; +use miden_tx::TransactionVerifierError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum BatchProveError { + #[error("failed to verify transaction {transaction_id} in transaction batch")] + TransactionVerificationFailed { + transaction_id: TransactionId, + source: TransactionVerifierError, + }, +} diff --git a/crates/miden-tx-batch-prover/src/lib.rs b/crates/miden-tx-batch-prover/src/lib.rs new file mode 100644 index 000000000..aa7d26f38 --- /dev/null +++ b/crates/miden-tx-batch-prover/src/lib.rs @@ -0,0 +1,18 @@ +#![no_std] + +#[cfg_attr(test, macro_use)] +extern crate alloc; + +#[cfg(feature = "std")] +extern crate std; + +mod local_batch_prover; +pub use local_batch_prover::LocalBatchProver; + +pub mod errors; + +#[cfg(test)] +pub mod testing; + +#[cfg(test)] +mod tests; diff --git a/crates/miden-tx-batch-prover/src/local_batch_prover.rs b/crates/miden-tx-batch-prover/src/local_batch_prover.rs new file mode 100644 index 000000000..be3efb5bd --- /dev/null +++ b/crates/miden-tx-batch-prover/src/local_batch_prover.rs @@ -0,0 +1,58 @@ +use miden_objects::batch::{ProposedBatch, ProvenBatch}; +use miden_tx::TransactionVerifier; + +use crate::errors::BatchProveError; + +// LOCAL BATCH PROVER +// ================================================================================================ + +/// A local prover for transaction batches, proving the transactions in a [`ProposedBatch`] and +/// returning a [`ProvenBatch`]. +pub struct LocalBatchProver { + proof_security_level: u32, +} + +impl LocalBatchProver { + /// Creates a new [`LocalBatchProver`] instance. + pub fn new(proof_security_level: u32) -> Self { + Self { proof_security_level } + } + + /// Attempts to prove the [`ProposedBatch`] into a [`ProvenBatch`]. + /// + /// # Errors + /// + /// Returns an error if: + /// - a proof of any transaction in the batch fails to verify. + pub fn prove(&self, proposed_batch: ProposedBatch) -> Result { + let ( + transactions, + _block_header, + _block_chain, + _authenticatable_unauthenticated_notes, + id, + updated_accounts, + input_notes, + output_notes_smt, + output_notes, + batch_expiration_block_num, + ) = proposed_batch.into_parts(); + + let verifier = TransactionVerifier::new(self.proof_security_level); + + for tx in transactions { + verifier.verify(&tx).map_err(|source| { + BatchProveError::TransactionVerificationFailed { transaction_id: tx.id(), source } + })?; + } + + Ok(ProvenBatch::new( + id, + updated_accounts, + input_notes, + output_notes_smt, + output_notes, + batch_expiration_block_num, + )) + } +} diff --git a/crates/miden-tx-batch-prover/src/testing/mod.rs b/crates/miden-tx-batch-prover/src/testing/mod.rs new file mode 100644 index 000000000..8f5eeaba3 --- /dev/null +++ b/crates/miden-tx-batch-prover/src/testing/mod.rs @@ -0,0 +1,2 @@ +mod proven_tx_builder; +pub(crate) use proven_tx_builder::MockProvenTxBuilder; diff --git a/crates/miden-tx-batch-prover/src/testing/proven_tx_builder.rs b/crates/miden-tx-batch-prover/src/testing/proven_tx_builder.rs new file mode 100644 index 000000000..5a4b30def --- /dev/null +++ b/crates/miden-tx-batch-prover/src/testing/proven_tx_builder.rs @@ -0,0 +1,111 @@ +use alloc::vec::Vec; + +use anyhow::Context; +use miden_crypto::merkle::MerklePath; +use miden_objects::{ + account::AccountId, + block::BlockNumber, + note::{Note, NoteInclusionProof, Nullifier}, + transaction::{InputNote, OutputNote, ProvenTransaction, ProvenTransactionBuilder}, + vm::ExecutionProof, +}; +use vm_processor::Digest; +use winterfell::Proof; + +/// A builder to build mocked [`ProvenTransaction`]s. +pub struct MockProvenTxBuilder { + account_id: AccountId, + initial_account_commitment: Digest, + final_account_commitment: Digest, + block_reference: Option, + expiration_block_num: BlockNumber, + output_notes: Option>, + input_notes: Option>, + nullifiers: Option>, +} + +impl MockProvenTxBuilder { + /// Creates a new builder for a transaction executed against the given account with its initial + /// and final state commitment. + pub fn with_account( + account_id: AccountId, + initial_account_commitment: Digest, + final_account_commitment: Digest, + ) -> Self { + Self { + account_id, + initial_account_commitment, + final_account_commitment, + block_reference: None, + expiration_block_num: BlockNumber::from(u32::MAX), + output_notes: None, + input_notes: None, + nullifiers: None, + } + } + + /// Adds unauthenticated notes to the transaction. + #[must_use] + pub fn authenticated_notes(mut self, notes: Vec) -> Self { + let mock_proof = + NoteInclusionProof::new(BlockNumber::from(0), 0, MerklePath::new(vec![])).unwrap(); + self.input_notes = Some( + notes + .into_iter() + .map(|note| InputNote::authenticated(note, mock_proof.clone())) + .collect(), + ); + + self + } + + /// Adds unauthenticated notes to the transaction. + #[must_use] + pub fn unauthenticated_notes(mut self, notes: Vec) -> Self { + self.input_notes = Some(notes.into_iter().map(InputNote::unauthenticated).collect()); + + self + } + + /// Sets the transaction's expiration block number. + #[must_use] + pub fn expiration_block_num(mut self, expiration_block_num: BlockNumber) -> Self { + self.expiration_block_num = expiration_block_num; + + self + } + + /// Adds notes to the transaction's output notes. + #[must_use] + pub fn output_notes(mut self, notes: Vec) -> Self { + self.output_notes = Some(notes); + + self + } + + /// Sets the transaction's block reference. + #[must_use] + pub fn block_reference(mut self, block_reference: Digest) -> Self { + self.block_reference = Some(block_reference); + + self + } + + /// Builds the [`ProvenTransaction`] and returns potential errors. + pub fn build(self) -> anyhow::Result { + ProvenTransactionBuilder::new( + self.account_id, + self.initial_account_commitment, + self.final_account_commitment, + BlockNumber::from(0), + self.block_reference.unwrap_or_default(), + self.expiration_block_num, + ExecutionProof::new(Proof::new_dummy(), Default::default()), + ) + .add_input_notes(self.input_notes.unwrap_or_default()) + .add_input_notes(self.nullifiers.unwrap_or_default()) + .add_output_notes(self.output_notes.unwrap_or_default()) + .build() + .context("failed to build proven transaction") + } +} diff --git a/crates/miden-tx-batch-prover/src/tests/mod.rs b/crates/miden-tx-batch-prover/src/tests/mod.rs new file mode 100644 index 000000000..19fd2c568 --- /dev/null +++ b/crates/miden-tx-batch-prover/src/tests/mod.rs @@ -0,0 +1 @@ +mod proposed_batch; diff --git a/crates/miden-tx-batch-prover/src/tests/proposed_batch.rs b/crates/miden-tx-batch-prover/src/tests/proposed_batch.rs new file mode 100644 index 000000000..7adc23c22 --- /dev/null +++ b/crates/miden-tx-batch-prover/src/tests/proposed_batch.rs @@ -0,0 +1,591 @@ +use alloc::sync::Arc; +use std::collections::BTreeMap; + +use anyhow::Context; +use miden_crypto::merkle::MerkleError; +use miden_lib::transaction::TransactionKernel; +use miden_objects::{ + account::{Account, AccountId}, + batch::ProposedBatch, + block::BlockNumber, + note::{Note, NoteType}, + testing::{account_id::AccountIdBuilder, note::NoteBuilder}, + transaction::{ChainMmr, InputNote, InputNoteCommitment, OutputNote}, + BatchAccountUpdateError, ProposedBatchError, +}; +use miden_tx::testing::{Auth, MockChain}; +use rand::{rngs::SmallRng, SeedableRng}; +use vm_core::assert_matches; +use vm_processor::Digest; + +use crate::testing::MockProvenTxBuilder; + +fn mock_account_id(num: u8) -> AccountId { + AccountIdBuilder::new().build_with_rng(&mut SmallRng::from_seed([num; 32])) +} + +pub fn mock_note(num: u8) -> Note { + let sender = mock_account_id(num); + NoteBuilder::new(sender, SmallRng::from_seed([num; 32])) + .build(&TransactionKernel::assembler().with_debug_mode(true)) + .unwrap() +} + +pub fn mock_output_note(num: u8) -> OutputNote { + OutputNote::Full(mock_note(num)) +} + +struct TestSetup { + chain: MockChain, + account1: Account, + account2: Account, +} + +fn setup_chain() -> TestSetup { + let mut chain = MockChain::new(); + let account1 = chain.add_new_wallet(Auth::NoAuth); + let account2 = chain.add_new_wallet(Auth::NoAuth); + chain.seal_block(None); + + TestSetup { chain, account1, account2 } +} + +/// Tests that a note created and consumed in the same batch are erased from the input and +/// output note commitments. +#[test] +fn empty_transaction_batch() -> anyhow::Result<()> { + let TestSetup { chain, .. } = setup_chain(); + let block1 = chain.block_header(1); + + let error = ProposedBatch::new(vec![], block1, chain.chain(), BTreeMap::default()).unwrap_err(); + + assert_matches!(error, ProposedBatchError::EmptyTransactionBatch); + + Ok(()) +} + +/// Tests that a note created and consumed in the same batch are erased from the input and +/// output note commitments. +#[test] +fn note_created_and_consumed_in_same_batch() -> anyhow::Result<()> { + let TestSetup { mut chain, account1, account2 } = setup_chain(); + let block1 = chain.block_header(1); + let block2 = chain.seal_block(None); + + let note = mock_note(40); + let tx1 = MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.hash()) + .block_reference(block1.hash()) + .output_notes(vec![OutputNote::Full(note.clone())]) + .build()?; + let tx2 = MockProvenTxBuilder::with_account(account2.id(), Digest::default(), account2.hash()) + .block_reference(block1.hash()) + .unauthenticated_notes(vec![note.clone()]) + .build()?; + + let batch = ProposedBatch::new( + [tx1, tx2].into_iter().map(Arc::new).collect(), + block2.header(), + chain.chain(), + BTreeMap::default(), + )?; + + assert_eq!(batch.input_notes().num_notes(), 0); + assert_eq!(batch.output_notes().len(), 0); + assert_eq!(batch.output_notes_tree().num_leaves(), 0); + + Ok(()) +} + +/// Tests that an error is returned if the same unauthenticated input note appears multiple +/// times in different transactions. +#[test] +fn duplicate_unauthenticated_input_notes() -> anyhow::Result<()> { + let TestSetup { chain, account1, account2 } = setup_chain(); + let block1 = chain.block_header(1); + + let note = mock_note(50); + let tx1 = MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.hash()) + .block_reference(block1.hash()) + .unauthenticated_notes(vec![note.clone()]) + .build()?; + let tx2 = MockProvenTxBuilder::with_account(account2.id(), Digest::default(), account2.hash()) + .block_reference(block1.hash()) + .unauthenticated_notes(vec![note.clone()]) + .build()?; + + let error = ProposedBatch::new( + [tx1.clone(), tx2.clone()].into_iter().map(Arc::new).collect(), + block1, + chain.chain(), + BTreeMap::default(), + ) + .unwrap_err(); + + assert_matches!(error, ProposedBatchError::DuplicateInputNote { + note_nullifier, + first_transaction_id, + second_transaction_id + } if note_nullifier == note.nullifier() && + first_transaction_id == tx1.id() && + second_transaction_id == tx2.id() + ); + + Ok(()) +} + +/// Tests that an error is returned if the same authenticated input note appears multiple +/// times in different transactions. +#[test] +fn duplicate_authenticated_input_notes() -> anyhow::Result<()> { + let TestSetup { mut chain, account1, account2 } = setup_chain(); + let note = chain.add_p2id_note(account1.id(), account2.id(), &[], NoteType::Private, None)?; + let block1 = chain.block_header(1); + let block2 = chain.seal_block(None); + + let tx1 = MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.hash()) + .block_reference(block1.hash()) + .authenticated_notes(vec![note.clone()]) + .build()?; + let tx2 = MockProvenTxBuilder::with_account(account2.id(), Digest::default(), account2.hash()) + .block_reference(block1.hash()) + .authenticated_notes(vec![note.clone()]) + .build()?; + + let error = ProposedBatch::new( + [tx1.clone(), tx2.clone()].into_iter().map(Arc::new).collect(), + block2.header(), + chain.chain(), + BTreeMap::default(), + ) + .unwrap_err(); + + assert_matches!(error, ProposedBatchError::DuplicateInputNote { + note_nullifier, + first_transaction_id, + second_transaction_id + } if note_nullifier == note.nullifier() && + first_transaction_id == tx1.id() && + second_transaction_id == tx2.id() + ); + + Ok(()) +} + +/// Tests that an error is returned if the same input note appears multiple times in different +/// transactions as an unauthenticated or authenticated note. +#[test] +fn duplicate_mixed_input_notes() -> anyhow::Result<()> { + let TestSetup { mut chain, account1, account2 } = setup_chain(); + let note = chain.add_p2id_note(account1.id(), account2.id(), &[], NoteType::Private, None)?; + let block1 = chain.block_header(1); + let block2 = chain.seal_block(None); + + let tx1 = MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.hash()) + .block_reference(block1.hash()) + .unauthenticated_notes(vec![note.clone()]) + .build()?; + let tx2 = MockProvenTxBuilder::with_account(account2.id(), Digest::default(), account2.hash()) + .block_reference(block1.hash()) + .authenticated_notes(vec![note.clone()]) + .build()?; + + let error = ProposedBatch::new( + [tx1.clone(), tx2.clone()].into_iter().map(Arc::new).collect(), + block2.header(), + chain.chain(), + BTreeMap::default(), + ) + .unwrap_err(); + + assert_matches!(error, ProposedBatchError::DuplicateInputNote { + note_nullifier, + first_transaction_id, + second_transaction_id + } if note_nullifier == note.nullifier() && + first_transaction_id == tx1.id() && + second_transaction_id == tx2.id() + ); + + Ok(()) +} + +/// Tests that an error is returned if the same output note appears multiple times in different +/// transactions. +#[test] +fn duplicate_output_notes() -> anyhow::Result<()> { + let TestSetup { chain, account1, account2 } = setup_chain(); + let block1 = chain.block_header(1); + + let note0 = mock_output_note(50); + let tx1 = MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.hash()) + .block_reference(block1.hash()) + .output_notes(vec![note0.clone()]) + .build()?; + let tx2 = MockProvenTxBuilder::with_account(account2.id(), Digest::default(), account2.hash()) + .block_reference(block1.hash()) + .output_notes(vec![note0.clone()]) + .build()?; + + let error = ProposedBatch::new( + [tx1.clone(), tx2.clone()].into_iter().map(Arc::new).collect(), + block1, + chain.chain(), + BTreeMap::default(), + ) + .unwrap_err(); + + assert_matches!(error, ProposedBatchError::DuplicateOutputNote { + note_id, + first_transaction_id, + second_transaction_id + } if note_id == note0.id() && + first_transaction_id == tx1.id() && + second_transaction_id == tx2.id()); + + Ok(()) +} + +/// Test that an unauthenticated input note for which a proof exists is converted into an +/// authenticated one and becomes part of the batch's input note commitment. +#[test] +fn unauthenticated_note_converted_to_authenticated() -> anyhow::Result<()> { + let TestSetup { mut chain, account1, account2 } = setup_chain(); + let note0 = chain.add_p2id_note(account2.id(), account1.id(), &[], NoteType::Private, None)?; + let note1 = chain.add_p2id_note(account1.id(), account2.id(), &[], NoteType::Private, None)?; + // The just created note will be provable against block2. + let block2 = chain.seal_block(None); + let block3 = chain.seal_block(None); + let block4 = chain.seal_block(None); + + // Consume the authenticated note as an unauthenticated one in the transaction. + let tx1 = MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.hash()) + .block_reference(block3.hash()) + .unauthenticated_notes(vec![note1.clone()]) + .build()?; + + let input_note0 = chain.available_notes_map().get(¬e0.id()).expect("note not found"); + let note_inclusion_proof0 = input_note0.proof().expect("note should be of type authenticated"); + + let input_note1 = chain.available_notes_map().get(¬e1.id()).expect("note not found"); + let note_inclusion_proof1 = input_note1.proof().expect("note should be of type authenticated"); + + // The chain MMR will contain all blocks in the mock chain, in particular block2 which both note + // inclusion proofs need for verification. + let chain_mmr = chain.chain(); + + // Case 1: Error: A wrong proof is passed. + // -------------------------------------------------------------------------------------------- + + let error = ProposedBatch::new( + [tx1.clone()].into_iter().map(Arc::new).collect(), + block4.header(), + chain_mmr.clone(), + BTreeMap::from_iter([(input_note1.id(), note_inclusion_proof0.clone())]), + ) + .unwrap_err(); + + assert_matches!(error, ProposedBatchError::UnauthenticatedNoteAuthenticationFailed { + note_id, + block_num, + source: MerkleError::ConflictingRoots { .. }, + } if note_id == note1.id() && + block_num == block2.header().block_num() + ); + + // Case 2: Error: The block referenced by the (valid) note inclusion proof is missing. + // -------------------------------------------------------------------------------------------- + + // Make a clone of the chain mmr where block2 is missing. + let mut mmr = chain_mmr.mmr().clone(); + mmr.untrack(block2.header().block_num().as_usize()); + let blocks = chain_mmr + .block_headers() + .filter(|header| header.block_num() != block2.header().block_num()) + .copied(); + + let error = ProposedBatch::new( + [tx1.clone()].into_iter().map(Arc::new).collect(), + block4.header(), + ChainMmr::new(mmr, blocks).context("failed to build chain mmr with missing block")?, + BTreeMap::from_iter([(input_note1.id(), note_inclusion_proof1.clone())]), + ) + .unwrap_err(); + + assert_matches!( + error, + ProposedBatchError::UnauthenticatedInputNoteBlockNotInChainMmr { + block_number, + note_id + } if block_number == note_inclusion_proof1.location().block_num() && + note_id == input_note1.id() + ); + + // Case 3: Success: The correct proof is passed. + // -------------------------------------------------------------------------------------------- + + let batch = ProposedBatch::new( + [tx1].into_iter().map(Arc::new).collect(), + block4.header(), + chain_mmr, + BTreeMap::from_iter([(input_note1.id(), note_inclusion_proof1.clone())]), + )?; + + // We expect the unauthenticated input note to have become an authenticated one, + // meaning it is part of the input note commitment. + assert_eq!(batch.input_notes().num_notes(), 1); + assert!(batch + .input_notes() + .iter() + .any(|commitment| commitment == &InputNoteCommitment::from(input_note1))); + assert_eq!(batch.output_notes().len(), 0); + + Ok(()) +} + +/// Test that an authenticated input note that is also created in the same batch does not error +/// and instead is marked as consumed. +/// - This requires a nullifier collision on the input and output note which is very unlikely in +/// practice. +/// - This makes the created note unspendable as its nullifier is added to the nullifier tree. +/// - The batch kernel cannot return an error in this case as it can't detect this condition due to +/// only having the nullifier for authenticated input notes _but_ not having the nullifier for +/// private output notes. +/// - We test this to ensure the kernel does something reasonable in this case and it is not an +/// attack vector. +#[test] +fn authenticated_note_created_in_same_batch() -> anyhow::Result<()> { + let TestSetup { mut chain, account1, account2 } = setup_chain(); + let note = chain.add_p2id_note(account1.id(), account2.id(), &[], NoteType::Private, None)?; + let block1 = chain.block_header(1); + let block2 = chain.seal_block(None); + + let note0 = mock_note(50); + let tx1 = MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.hash()) + .block_reference(block1.hash()) + .output_notes(vec![OutputNote::Full(note0.clone())]) + .build()?; + let tx2 = MockProvenTxBuilder::with_account(account2.id(), Digest::default(), account2.hash()) + .block_reference(block1.hash()) + .authenticated_notes(vec![note.clone()]) + .build()?; + + let batch = ProposedBatch::new( + [tx1, tx2].into_iter().map(Arc::new).collect(), + block2.header(), + chain.chain(), + BTreeMap::default(), + )?; + + assert_eq!(batch.input_notes().num_notes(), 1); + assert_eq!(batch.output_notes().len(), 1); + assert_eq!(batch.output_notes_tree().num_leaves(), 1); + + Ok(()) +} + +/// Test that multiple transactions against the same account +/// 1) can be correctly executed when in the right order, +/// 2) and that an error is returned if they are incorrectly ordered. +#[test] +fn multiple_transactions_against_same_account() -> anyhow::Result<()> { + let TestSetup { chain, account1, .. } = setup_chain(); + let block1 = chain.block_header(1); + + // Use some random hash as the initial state commitment of tx1. + let initial_state_commitment = Digest::default(); + let tx1 = + MockProvenTxBuilder::with_account(account1.id(), initial_state_commitment, account1.hash()) + .block_reference(block1.hash()) + .output_notes(vec![mock_output_note(0)]) + .build()?; + + // Use some random hash as the final state commitment of tx2. + let final_state_commitment = mock_note(10).hash(); + let tx2 = + MockProvenTxBuilder::with_account(account1.id(), account1.hash(), final_state_commitment) + .block_reference(block1.hash()) + .build()?; + + // Success: Transactions are correctly ordered. + let batch = ProposedBatch::new( + [tx1.clone(), tx2.clone()].into_iter().map(Arc::new).collect(), + block1, + chain.chain(), + BTreeMap::default(), + )?; + + assert_eq!(batch.account_updates().len(), 1); + // Assert that the initial state commitment from tx1 is used and the final state commitment + // from tx2. + assert_eq!( + batch.account_updates().get(&account1.id()).unwrap().initial_state_commitment(), + initial_state_commitment + ); + assert_eq!( + batch.account_updates().get(&account1.id()).unwrap().final_state_commitment(), + final_state_commitment + ); + + // Error: Transactions are incorrectly ordered. + let error = ProposedBatch::new( + [tx2.clone(), tx1.clone()].into_iter().map(Arc::new).collect(), + block1, + chain.chain(), + BTreeMap::default(), + ) + .unwrap_err(); + + assert_matches!( + error, + ProposedBatchError::AccountUpdateError { + source: BatchAccountUpdateError::AccountUpdateInitialStateMismatch(tx_id), + .. + } if tx_id == tx1.id() + ); + + Ok(()) +} + +/// Tests that the input and outputs notes commitment is correctly computed. +/// - Notes created and consumed in the same batch are erased from these commitments. +/// - The input note commitment is sorted by the order in which the notes appeared in the batch. +/// - The output note commitment is sorted by [`NoteId`]. +#[test] +fn input_and_output_notes_commitment() -> anyhow::Result<()> { + let TestSetup { chain, account1, account2 } = setup_chain(); + let block1 = chain.block_header(1); + + let note0 = mock_output_note(50); + let note1 = mock_note(60); + let note2 = mock_output_note(70); + let note3 = mock_output_note(80); + let note4 = mock_note(90); + let note5 = mock_note(100); + + let tx1 = MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.hash()) + .block_reference(block1.hash()) + .unauthenticated_notes(vec![note1.clone(), note5.clone()]) + .output_notes(vec![note0.clone()]) + .build()?; + let tx2 = MockProvenTxBuilder::with_account(account2.id(), Digest::default(), account2.hash()) + .block_reference(block1.hash()) + .unauthenticated_notes(vec![note4.clone()]) + .output_notes(vec![OutputNote::Full(note1.clone()), note2.clone(), note3.clone()]) + .build()?; + + let batch = ProposedBatch::new( + [tx1.clone(), tx2.clone()].into_iter().map(Arc::new).collect(), + block1, + chain.chain(), + BTreeMap::default(), + )?; + + // We expecte note1 to be erased from the input/output notes as it is created and consumed + // in the batch. + let mut expected_output_notes = [note0, note2, note3]; + // We expect a vector sorted by NoteId. + expected_output_notes.sort_unstable_by_key(OutputNote::id); + + assert_eq!(batch.output_notes().len(), 3); + assert_eq!(batch.output_notes(), expected_output_notes); + + assert_eq!(batch.output_notes_tree().num_leaves(), 3); + + // Input notes are sorted by the order in which they appeared in the batch. + assert_eq!(batch.input_notes().num_notes(), 2); + assert_eq!( + batch.input_notes().clone().into_vec(), + &[ + InputNoteCommitment::from(&InputNote::unauthenticated(note5)), + InputNoteCommitment::from(&InputNote::unauthenticated(note4)), + ] + ); + + Ok(()) +} + +/// Tests that the expiration block number of a batch is the minimum of all contained transactions. +#[test] +fn batch_expiration() -> anyhow::Result<()> { + let TestSetup { chain, account1, account2 } = setup_chain(); + let block1 = chain.block_header(1); + + let tx1 = MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.hash()) + .block_reference(block1.hash()) + .expiration_block_num(BlockNumber::from(35)) + .build()?; + let tx2 = MockProvenTxBuilder::with_account(account2.id(), Digest::default(), account2.hash()) + .block_reference(block1.hash()) + .expiration_block_num(BlockNumber::from(30)) + .build()?; + + let batch = ProposedBatch::new( + [tx1, tx2].into_iter().map(Arc::new).collect(), + block1, + chain.chain(), + BTreeMap::default(), + )?; + + assert_eq!(batch.batch_expiration_block_num(), BlockNumber::from(30)); + + Ok(()) +} + +/// Tests that passing duplicate transactions in a batch returns an error. +#[test] +fn duplicate_transaction() -> anyhow::Result<()> { + let TestSetup { chain, account1, .. } = setup_chain(); + let block1 = chain.block_header(1); + + let tx1 = MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.hash()) + .block_reference(block1.hash()) + .expiration_block_num(BlockNumber::from(35)) + .build()?; + + let error = ProposedBatch::new( + [tx1.clone(), tx1.clone()].into_iter().map(Arc::new).collect(), + block1, + chain.chain(), + BTreeMap::default(), + ) + .unwrap_err(); + + assert_matches!(error, ProposedBatchError::DuplicateTransaction { transaction_id } if transaction_id == tx1.id()); + + Ok(()) +} + +/// Tests that transactions with a circular dependency between notes are accepted: +/// TX 1: Inputs [X] -> Outputs [Y] +/// TX 2: Inputs [Y] -> Outputs [X] +#[test] +fn circular_note_dependency() -> anyhow::Result<()> { + let TestSetup { chain, account1, account2 } = setup_chain(); + let block1 = chain.block_header(1); + + let note_x = mock_note(20); + let note_y = mock_note(30); + + let tx1 = MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.hash()) + .block_reference(block1.hash()) + .unauthenticated_notes(vec![note_x.clone()]) + .output_notes(vec![OutputNote::Full(note_y.clone())]) + .build()?; + let tx2 = MockProvenTxBuilder::with_account(account2.id(), Digest::default(), account2.hash()) + .block_reference(block1.hash()) + .unauthenticated_notes(vec![note_y.clone()]) + .output_notes(vec![OutputNote::Full(note_x.clone())]) + .build()?; + + let batch = ProposedBatch::new( + [tx1, tx2].into_iter().map(Arc::new).collect(), + block1, + chain.chain(), + BTreeMap::default(), + )?; + + assert_eq!(batch.input_notes().num_notes(), 0); + assert_eq!(batch.output_notes().len(), 0); + + Ok(()) +} diff --git a/crates/miden-tx/src/prover/mod.rs b/crates/miden-tx/src/prover/mod.rs index e6445e665..c1c5ed14c 100644 --- a/crates/miden-tx/src/prover/mod.rs +++ b/crates/miden-tx/src/prover/mod.rs @@ -95,6 +95,7 @@ impl TransactionProver for LocalTransactionProver { let account = tx_inputs.account(); let input_notes = tx_inputs.input_notes(); + let block_num = tx_inputs.block_header().block_num(); let block_hash = tx_inputs.block_header().hash(); // execute and prove @@ -137,6 +138,7 @@ impl TransactionProver for LocalTransactionProver { account.id(), account.init_hash(), tx_outputs.account.hash(), + block_num, block_hash, tx_outputs.expiration_block_num, proof, diff --git a/crates/miden-tx/src/testing/mock_chain/mod.rs b/crates/miden-tx/src/testing/mock_chain/mod.rs index 4e873aa29..c346cb1bc 100644 --- a/crates/miden-tx/src/testing/mock_chain/mod.rs +++ b/crates/miden-tx/src/testing/mock_chain/mod.rs @@ -17,7 +17,7 @@ use miden_objects::{ }, crypto::{ dsa::rpo_falcon512::SecretKey, - merkle::{Mmr, MmrError, PartialMmr, Smt}, + merkle::{Mmr, Smt}, }, note::{Note, NoteId, NoteInclusionProof, NoteType, Nullifier}, testing::account_code::DEFAULT_AUTH_SCRIPT, @@ -633,8 +633,8 @@ impl MockChain { input_notes.push(InputNote::Unauthenticated { note: note.clone() }) } - let block_headers: Vec = block_headers_map.values().cloned().collect(); - let mmr = mmr_to_chain_mmr(&self.chain, &block_headers).unwrap(); + let block_headers = block_headers_map.values().cloned(); + let mmr = ChainMmr::from_mmr(&self.chain, block_headers).unwrap(); TransactionInputs::new( account, @@ -782,8 +782,11 @@ impl MockChain { /// Gets the latest [ChainMmr]. pub fn chain(&self) -> ChainMmr { - let block_headers: Vec = self.blocks.iter().map(|b| b.header()).collect(); - mmr_to_chain_mmr(&self.chain, &block_headers).unwrap() + // We cannot pass the latest block as that would violate the condition in the transaction + // inputs that the chain length of the mmr must match the number of the reference block. + let block_headers = self.blocks.iter().map(|b| b.header()).take(self.blocks.len() - 1); + + ChainMmr::from_mmr(&self.chain, block_headers).unwrap() } /// Gets a reference to [BlockHeader] with `block_number`. @@ -801,6 +804,11 @@ impl MockChain { self.available_notes.values().cloned().collect() } + /// Returns the map of note IDs to consumable input notes. + pub fn available_notes_map(&self) -> &BTreeMap { + &self.available_notes + } + /// Get the reference to the accounts hash tree. pub fn accounts(&self) -> &SimpleSmt { &self.accounts @@ -816,20 +824,3 @@ enum AccountState { New, Exists, } - -// HELPER FUNCTIONS -// ================================================================================================ - -/// Converts the MMR into partial MMR by copying all leaves from MMR to partial MMR. -fn mmr_to_chain_mmr(mmr: &Mmr, blocks: &[BlockHeader]) -> Result { - let target_forest = mmr.forest() - 1; - let mut partial_mmr = PartialMmr::from_peaks(mmr.peaks_at(target_forest)?); - - for i in 0..target_forest { - let node = mmr.get(i)?; - let path = mmr.open_at(i, target_forest)?.merkle_path; - partial_mmr.track(i, node, &path)?; - } - - Ok(ChainMmr::new(partial_mmr, blocks.to_vec()).unwrap()) -} diff --git a/crates/miden-tx/src/tests/mod.rs b/crates/miden-tx/src/tests/mod.rs index 09ba08157..45e75cc45 100644 --- a/crates/miden-tx/src/tests/mod.rs +++ b/crates/miden-tx/src/tests/mod.rs @@ -829,7 +829,7 @@ fn prove_witness_and_verify() { let serialized_transaction = proven_transaction.to_bytes(); let proven_transaction = ProvenTransaction::read_from_bytes(&serialized_transaction).unwrap(); let verifier = TransactionVerifier::new(MIN_PROOF_SECURITY_LEVEL); - assert!(verifier.verify(proven_transaction).is_ok()); + assert!(verifier.verify(&proven_transaction).is_ok()); } // TEST TRANSACTION SCRIPT diff --git a/crates/miden-tx/src/verifier/mod.rs b/crates/miden-tx/src/verifier/mod.rs index 6e6b8e6ae..d42b4944c 100644 --- a/crates/miden-tx/src/verifier/mod.rs +++ b/crates/miden-tx/src/verifier/mod.rs @@ -30,7 +30,7 @@ impl TransactionVerifier { /// Returns an error if: /// - Transaction verification fails. /// - The security level of the verified proof is insufficient. - pub fn verify(&self, transaction: ProvenTransaction) -> Result<(), TransactionVerifierError> { + pub fn verify(&self, transaction: &ProvenTransaction) -> Result<(), TransactionVerifierError> { // build stack inputs and outputs let stack_inputs = TransactionKernel::build_input_stack( transaction.account_id(), diff --git a/crates/miden-tx/tests/integration/main.rs b/crates/miden-tx/tests/integration/main.rs index 9842315a6..a43fee853 100644 --- a/crates/miden-tx/tests/integration/main.rs +++ b/crates/miden-tx/tests/integration/main.rs @@ -61,7 +61,7 @@ pub fn prove_and_verify_transaction( // Verify that the generated proof is valid let verifier = TransactionVerifier::new(miden_objects::MIN_PROOF_SECURITY_LEVEL); - verifier.verify(proven_transaction) + verifier.verify(&proven_transaction) } #[cfg(test)]