miden_objects/batch/proposed_batch.rs
1use alloc::{
2 collections::{BTreeMap, BTreeSet, btree_map::Entry},
3 sync::Arc,
4 vec::Vec,
5};
6
7use crate::{
8 MAX_ACCOUNTS_PER_BATCH, MAX_INPUT_NOTES_PER_BATCH, MAX_OUTPUT_NOTES_PER_BATCH,
9 account::AccountId,
10 batch::{BatchAccountUpdate, BatchId, InputOutputNoteTracker},
11 block::{BlockHeader, BlockNumber},
12 errors::ProposedBatchError,
13 note::{NoteId, NoteInclusionProof},
14 transaction::{ChainMmr, InputNoteCommitment, InputNotes, OutputNote, ProvenTransaction},
15 utils::{ByteReader, ByteWriter, Deserializable, DeserializationError, Serializable},
16};
17
18/// A proposed batch of transactions with all necessary data to validate it.
19///
20/// See [`ProposedBatch::new`] for what a proposed batch expects and guarantees.
21///
22/// This type is fairly large, so consider boxing it.
23#[derive(Debug, Clone)]
24pub struct ProposedBatch {
25 /// The transactions of this batch.
26 transactions: Vec<Arc<ProvenTransaction>>,
27 /// The header is boxed as it has a large stack size.
28 reference_block_header: BlockHeader,
29 /// The chain MMR used to authenticate:
30 /// - all unauthenticated notes that can be authenticated,
31 /// - all block commitments referenced by the transactions in the batch.
32 chain_mmr: ChainMmr,
33 /// The note inclusion proofs for unauthenticated notes that were consumed in the batch which
34 /// can be authenticated.
35 unauthenticated_note_proofs: BTreeMap<NoteId, NoteInclusionProof>,
36 /// The ID of the batch, which is a cryptographic commitment to the transactions in the batch.
37 id: BatchId,
38 /// A map from account ID's updated in this batch to the aggregated update from all
39 /// transaction's that touched the account.
40 account_updates: BTreeMap<AccountId, BatchAccountUpdate>,
41 /// The block number at which the batch will expire. This is the minimum of all transaction's
42 /// expiration block number.
43 batch_expiration_block_num: BlockNumber,
44 /// The input note commitment of the transaction batch. This consists of all authenticated
45 /// notes that transactions in the batch consume as well as unauthenticated notes whose
46 /// authentication is delayed to the block kernel. These are sorted by
47 /// [`InputNoteCommitment::nullifier`].
48 input_notes: InputNotes<InputNoteCommitment>,
49 /// The output notes of this batch. This consists of all notes created by transactions in the
50 /// batch that are not consumed within the same batch. These are sorted by [`OutputNote::id`].
51 output_notes: Vec<OutputNote>,
52}
53
54impl ProposedBatch {
55 // CONSTRUCTORS
56 // --------------------------------------------------------------------------------------------
57
58 /// Creates a new [`ProposedBatch`] from the provided parts.
59 ///
60 /// # Inputs
61 ///
62 /// - The given transactions must be correctly ordered. That is, if two transactions A and B
63 /// update the same account in this order, meaning A's initial account state commitment
64 /// matches the account state before any transactions are executed and B's initial account
65 /// state commitment matches the final account state commitment of A, then A must come before
66 /// B.
67 /// - The chain MMR's hashed peaks must match the reference block's `chain_commitment` and it
68 /// must contain all block headers:
69 /// - that are referenced by note inclusion proofs in `unauthenticated_note_proofs`.
70 /// - that are referenced by a transaction in the batch.
71 /// - The `unauthenticated_note_proofs` should contain [`NoteInclusionProof`]s for any
72 /// unauthenticated note consumed by the transaction's in the batch which can be
73 /// authenticated. This means it is not required that every unauthenticated note has an entry
74 /// in this map for two reasons.
75 /// - Unauthenticated note authentication can be delayed to the block kernel.
76 /// - Another transaction in the batch creates an output note matching an unauthenticated
77 /// input note, in which case inclusion in the chain does not need to be proven.
78 /// - The reference block of a batch must satisfy the following requirement: Its block number
79 /// must be greater or equal to the highest block number referenced by any transaction. This
80 /// is not verified explicitly, but will implicitly cause an error during the validation that
81 /// each reference block of a transaction is in the chain MMR.
82 ///
83 /// # Errors
84 ///
85 /// Returns an error if:
86 ///
87 /// - The number of input notes exceeds [`MAX_INPUT_NOTES_PER_BATCH`].
88 /// - Note that unauthenticated notes that are created in the same batch do not count. Any
89 /// other input notes, unauthenticated or not, do count.
90 /// - The number of output notes exceeds [`MAX_OUTPUT_NOTES_PER_BATCH`].
91 /// - Note that output notes that are consumed in the same batch as unauthenticated input
92 /// notes do not count.
93 /// - Any note is consumed more than once.
94 /// - Any note is created more than once.
95 /// - The number of account updates exceeds [`MAX_ACCOUNTS_PER_BATCH`].
96 /// - Note that any number of transactions against the same account count as one update.
97 /// - The chain MMRs chain length does not match the block header's block number. This means the
98 /// chain MMR should not contain the block header itself as it is added to the MMR in the
99 /// batch kernel.
100 /// - The chain MMRs hashed peaks do not match the block header's chain commitment.
101 /// - The reference block of any transaction is not in the chain MMR.
102 /// - The note inclusion proof for an unauthenticated note fails to verify.
103 /// - The block referenced by a note inclusion proof for an unauthenticated note is missing from
104 /// the chain MMR.
105 /// - The transactions in the proposed batch which update the same account are not correctly
106 /// ordered.
107 /// - The provided list of transactions is empty. An empty batch is pointless and would
108 /// potentially result in the same [`BatchId`] for two empty batches which would mean batch
109 /// IDs are no longer unique.
110 /// - There are duplicate transactions.
111 /// - If any transaction's expiration block number is less than or equal to the batch's
112 /// reference block.
113 pub fn new(
114 transactions: Vec<Arc<ProvenTransaction>>,
115 reference_block_header: BlockHeader,
116 chain_mmr: ChainMmr,
117 unauthenticated_note_proofs: BTreeMap<NoteId, NoteInclusionProof>,
118 ) -> Result<Self, ProposedBatchError> {
119 // Check for empty or duplicate transactions.
120 // --------------------------------------------------------------------------------------------
121
122 if transactions.is_empty() {
123 return Err(ProposedBatchError::EmptyTransactionBatch);
124 }
125
126 let mut transaction_set = BTreeSet::new();
127 for tx in transactions.iter() {
128 if !transaction_set.insert(tx.id()) {
129 return Err(ProposedBatchError::DuplicateTransaction { transaction_id: tx.id() });
130 }
131 }
132
133 // Verify block header and chain MMR match.
134 // --------------------------------------------------------------------------------------------
135
136 if chain_mmr.chain_length() != reference_block_header.block_num() {
137 return Err(ProposedBatchError::InconsistentChainLength {
138 expected: reference_block_header.block_num(),
139 actual: chain_mmr.chain_length(),
140 });
141 }
142
143 let hashed_peaks = chain_mmr.peaks().hash_peaks();
144 if hashed_peaks != reference_block_header.chain_commitment() {
145 return Err(ProposedBatchError::InconsistentChainRoot {
146 expected: reference_block_header.chain_commitment(),
147 actual: hashed_peaks,
148 });
149 }
150
151 // Verify all block references from the transactions are in the chain MMR, except for the
152 // batch's reference block.
153 //
154 // Note that some block X is only added to the blockchain MMR by block X + 1. This is
155 // because block X cannot compute its own block commitment and thus cannot add itself to the
156 // chain. So, more generally, a previous block is added to the blockchain by its child
157 // block.
158 //
159 // The reference block of a batch may be the latest block in the chain and, as mentioned,
160 // block is not yet part of the blockchain MMR, so its inclusion cannot be proven. Since the
161 // inclusion cannot be proven in all cases, the batch kernel instead commits to this
162 // reference block's commitment as a public input, which means the block kernel will prove
163 // this block's inclusion when including this batch and verifying its ZK proof.
164 //
165 // Finally, note that we don't verify anything cryptographically here. We have previously
166 // verified that the batch reference block's chain commitment matches the hashed peaks of
167 // the `ChainMmr`, and so we only have to check if the chain MMR contains the block
168 // here.
169 // --------------------------------------------------------------------------------------------
170
171 for tx in transactions.iter() {
172 if reference_block_header.block_num() != tx.ref_block_num()
173 && !chain_mmr.contains_block(tx.ref_block_num())
174 {
175 return Err(ProposedBatchError::MissingTransactionBlockReference {
176 block_reference: tx.ref_block_commitment(),
177 transaction_id: tx.id(),
178 });
179 }
180 }
181
182 // Aggregate individual tx-level account updates into a batch-level account update - one per
183 // account.
184 // --------------------------------------------------------------------------------------------
185
186 // Populate batch output notes and updated accounts.
187 let mut account_updates = BTreeMap::<AccountId, BatchAccountUpdate>::new();
188 for tx in transactions.iter() {
189 // Merge account updates so that state transitions A->B->C become A->C.
190 match account_updates.entry(tx.account_id()) {
191 Entry::Vacant(vacant) => {
192 let batch_account_update = BatchAccountUpdate::from_transaction(tx);
193 vacant.insert(batch_account_update);
194 },
195 Entry::Occupied(occupied) => {
196 // This returns an error if the transactions are not correctly ordered, e.g. if
197 // B comes before A.
198 occupied.into_mut().merge_proven_tx(tx).map_err(|source| {
199 ProposedBatchError::AccountUpdateError {
200 account_id: tx.account_id(),
201 source,
202 }
203 })?;
204 },
205 };
206 }
207
208 if account_updates.len() > MAX_ACCOUNTS_PER_BATCH {
209 return Err(ProposedBatchError::TooManyAccountUpdates(account_updates.len()));
210 }
211
212 // Check that all transaction's expiration block numbers are greater than the reference
213 // block.
214 // --------------------------------------------------------------------------------------------
215
216 let mut batch_expiration_block_num = BlockNumber::from(u32::MAX);
217 for tx in transactions.iter() {
218 if tx.expiration_block_num() <= reference_block_header.block_num() {
219 return Err(ProposedBatchError::ExpiredTransaction {
220 transaction_id: tx.id(),
221 transaction_expiration_num: tx.expiration_block_num(),
222 reference_block_num: reference_block_header.block_num(),
223 });
224 }
225
226 // The expiration block of the batch is the minimum of all transaction's expiration
227 // block.
228 batch_expiration_block_num = batch_expiration_block_num.min(tx.expiration_block_num());
229 }
230
231 // Check for duplicates in input notes.
232 // --------------------------------------------------------------------------------------------
233
234 // Check for duplicate input notes both within a transaction and across transactions.
235 // This also includes authenticated notes, as the transaction kernel doesn't check for
236 // duplicates.
237 let mut input_note_map = BTreeMap::new();
238
239 for tx in transactions.iter() {
240 for note in tx.input_notes() {
241 let nullifier = note.nullifier();
242 if let Some(first_transaction_id) = input_note_map.insert(nullifier, tx.id()) {
243 return Err(ProposedBatchError::DuplicateInputNote {
244 note_nullifier: nullifier,
245 first_transaction_id,
246 second_transaction_id: tx.id(),
247 });
248 }
249 }
250 }
251
252 // Create input and output note set of the batch.
253 // --------------------------------------------------------------------------------------------
254
255 // Check for duplicate output notes and remove all output notes from the batch output note
256 // set that are consumed by transactions.
257 let (input_notes, output_notes) = InputOutputNoteTracker::from_transactions(
258 transactions.iter().map(AsRef::as_ref),
259 &unauthenticated_note_proofs,
260 &chain_mmr,
261 &reference_block_header,
262 )?;
263
264 if input_notes.len() > MAX_INPUT_NOTES_PER_BATCH {
265 return Err(ProposedBatchError::TooManyInputNotes(input_notes.len()));
266 }
267 // SAFETY: This is safe as we have checked for duplicates and the max number of input notes
268 // in a batch.
269 let input_notes = InputNotes::new_unchecked(input_notes);
270
271 if output_notes.len() > MAX_OUTPUT_NOTES_PER_BATCH {
272 return Err(ProposedBatchError::TooManyOutputNotes(output_notes.len()));
273 }
274
275 // Compute batch ID.
276 // --------------------------------------------------------------------------------------------
277
278 let id = BatchId::from_transactions(transactions.iter().map(AsRef::as_ref));
279
280 Ok(Self {
281 id,
282 transactions,
283 reference_block_header,
284 chain_mmr,
285 unauthenticated_note_proofs,
286 account_updates,
287 batch_expiration_block_num,
288 input_notes,
289 output_notes,
290 })
291 }
292
293 // PUBLIC ACCESSORS
294 // --------------------------------------------------------------------------------------------
295
296 /// Returns a slice of the [`ProvenTransaction`]s in the batch.
297 pub fn transactions(&self) -> &[Arc<ProvenTransaction>] {
298 &self.transactions
299 }
300
301 /// Returns the map of account IDs mapped to their [`BatchAccountUpdate`]s.
302 ///
303 /// If an account was updated by multiple transactions, the [`BatchAccountUpdate`] is the result
304 /// of merging the individual updates.
305 ///
306 /// For example, suppose an account's state before this batch is `A` and the batch contains two
307 /// transactions that updated it. Applying the first transaction results in intermediate state
308 /// `B`, and applying the second one results in state `C`. Then the returned update represents
309 /// the state transition from `A` to `C`.
310 pub fn account_updates(&self) -> &BTreeMap<AccountId, BatchAccountUpdate> {
311 &self.account_updates
312 }
313
314 /// The ID of this batch. See [`BatchId`] for details on how it is computed.
315 pub fn id(&self) -> BatchId {
316 self.id
317 }
318
319 /// Returns the block number at which the batch will expire.
320 pub fn batch_expiration_block_num(&self) -> BlockNumber {
321 self.batch_expiration_block_num
322 }
323
324 /// Returns the [`InputNotes`] of this batch.
325 pub fn input_notes(&self) -> &InputNotes<InputNoteCommitment> {
326 &self.input_notes
327 }
328
329 /// Returns the output notes of the batch.
330 ///
331 /// This is the aggregation of all output notes by the transactions in the batch, except the
332 /// ones that were consumed within the batch itself.
333 pub fn output_notes(&self) -> &[OutputNote] {
334 &self.output_notes
335 }
336
337 /// Consumes the proposed batch and returns its underlying parts.
338 #[allow(clippy::type_complexity)]
339 pub fn into_parts(
340 self,
341 ) -> (
342 Vec<Arc<ProvenTransaction>>,
343 BlockHeader,
344 ChainMmr,
345 BTreeMap<NoteId, NoteInclusionProof>,
346 BatchId,
347 BTreeMap<AccountId, BatchAccountUpdate>,
348 InputNotes<InputNoteCommitment>,
349 Vec<OutputNote>,
350 BlockNumber,
351 ) {
352 (
353 self.transactions,
354 self.reference_block_header,
355 self.chain_mmr,
356 self.unauthenticated_note_proofs,
357 self.id,
358 self.account_updates,
359 self.input_notes,
360 self.output_notes,
361 self.batch_expiration_block_num,
362 )
363 }
364}
365
366// SERIALIZATION
367// ================================================================================================
368
369impl Serializable for ProposedBatch {
370 fn write_into<W: ByteWriter>(&self, target: &mut W) {
371 self.transactions
372 .iter()
373 .map(|tx| tx.as_ref().clone())
374 .collect::<Vec<ProvenTransaction>>()
375 .write_into(target);
376
377 self.reference_block_header.write_into(target);
378 self.chain_mmr.write_into(target);
379 self.unauthenticated_note_proofs.write_into(target);
380 }
381}
382
383impl Deserializable for ProposedBatch {
384 fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
385 let transactions = Vec::<ProvenTransaction>::read_from(source)?
386 .into_iter()
387 .map(Arc::new)
388 .collect::<Vec<Arc<ProvenTransaction>>>();
389
390 let block_header = BlockHeader::read_from(source)?;
391 let chain_mmr = ChainMmr::read_from(source)?;
392 let unauthenticated_note_proofs =
393 BTreeMap::<NoteId, NoteInclusionProof>::read_from(source)?;
394
395 ProposedBatch::new(transactions, block_header, chain_mmr, unauthenticated_note_proofs)
396 .map_err(|source| {
397 DeserializationError::UnknownError(format!(
398 "failed to create proposed batch: {source}"
399 ))
400 })
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use anyhow::Context;
407 use miden_crypto::merkle::{Mmr, PartialMmr};
408 use miden_verifier::ExecutionProof;
409 use winter_air::proof::Proof;
410 use winter_rand_utils::rand_array;
411
412 use super::*;
413 use crate::{
414 Digest, Word,
415 account::{AccountIdVersion, AccountStorageMode, AccountType},
416 transaction::ProvenTransactionBuilder,
417 };
418
419 #[test]
420 fn proposed_batch_serialization() -> anyhow::Result<()> {
421 // create chain MMR with 3 blocks - i.e., 2 peaks
422 let mut mmr = Mmr::default();
423 for i in 0..3 {
424 let block_header = BlockHeader::mock(i, None, None, &[], Digest::default());
425 mmr.add(block_header.commitment());
426 }
427 let partial_mmr: PartialMmr = mmr.peaks().into();
428 let chain_mmr = ChainMmr::new(partial_mmr, Vec::new()).unwrap();
429
430 let chain_commitment = chain_mmr.peaks().hash_peaks();
431 let note_root: Word = rand_array();
432 let tx_kernel_commitment: Word = rand_array();
433 let reference_block_header = BlockHeader::mock(
434 3,
435 Some(chain_commitment),
436 Some(note_root.into()),
437 &[],
438 tx_kernel_commitment.into(),
439 );
440
441 let account_id = AccountId::dummy(
442 [1; 15],
443 AccountIdVersion::Version0,
444 AccountType::FungibleFaucet,
445 AccountStorageMode::Private,
446 );
447 let initial_account_commitment =
448 [2; 32].try_into().expect("failed to create initial account commitment");
449 let final_account_commitment =
450 [3; 32].try_into().expect("failed to create final account commitment");
451 let block_num = reference_block_header.block_num();
452 let block_ref = reference_block_header.commitment();
453 let expiration_block_num = reference_block_header.block_num() + 1;
454 let proof = ExecutionProof::new(Proof::new_dummy(), Default::default());
455
456 let tx = ProvenTransactionBuilder::new(
457 account_id,
458 initial_account_commitment,
459 final_account_commitment,
460 block_num,
461 block_ref,
462 expiration_block_num,
463 proof,
464 )
465 .build()
466 .context("failed to build proven transaction")?;
467
468 let batch = ProposedBatch::new(
469 vec![Arc::new(tx)],
470 reference_block_header,
471 chain_mmr,
472 BTreeMap::new(),
473 )
474 .context("failed to propose batch")?;
475
476 let encoded_batch = batch.to_bytes();
477
478 let batch2 = ProposedBatch::read_from_bytes(&encoded_batch)
479 .context("failed to deserialize proposed batch")?;
480
481 assert_eq!(batch.transactions(), batch2.transactions());
482 assert_eq!(batch.reference_block_header, batch2.reference_block_header);
483 assert_eq!(batch.chain_mmr, batch2.chain_mmr);
484 assert_eq!(batch.unauthenticated_note_proofs, batch2.unauthenticated_note_proofs);
485 assert_eq!(batch.id, batch2.id);
486 assert_eq!(batch.account_updates, batch2.account_updates);
487 assert_eq!(batch.batch_expiration_block_num, batch2.batch_expiration_block_num);
488 assert_eq!(batch.input_notes, batch2.input_notes);
489 assert_eq!(batch.output_notes, batch2.output_notes);
490
491 Ok(())
492 }
493}