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