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 block is added to the blockchain by its child
169 // block.
170 //
171 // The reference block of a batch may be the latest block in the chain and, as mentioned,
172 // the block is not yet part of the blockchain, so its inclusion cannot be proven.
173 // Since the inclusion cannot be proven, the batch kernel instead commits to this reference
174 // block's commitment as a public input, which means the block kernel will prove
175 // this block's inclusion when including this batch and verifying its ZK proof.
176 //
177 // Finally, note that we don't verify anything cryptographically here. We have previously
178 // verified that the chain commitment of the batch's reference block matches the hashed
179 // peaks of the `PartialBlockchain`. This means the provided blockchain is consistent with
180 // the batch's reference block and that all blocks contained in the blockchain are
181 // consistent, too. So, as long as each transaction's reference block (number and
182 // commitment) is contained in the partial blockchain, we know the transaction's
183 // block header is consistent with the batch's reference block, too.
184 // --------------------------------------------------------------------------------------------
185
186 for tx in transactions.iter() {
187 // Differentiate between validation against the batch's reference block or a block from
188 // the chain (see above).
189 if reference_block_header.block_num() == tx.ref_block_num() {
190 if reference_block_header.commitment() != tx.ref_block_commitment() {
191 return Err(ProposedBatchError::TransactionReferenceBlockCommitmentMismatch {
192 transaction_id: tx.id(),
193 block_num: tx.ref_block_num(),
194 actual_block_commitment: tx.ref_block_commitment(),
195 expected_block_commitment: reference_block_header.commitment(),
196 });
197 }
198 } else {
199 let block_header =
200 partial_blockchain.get_block(tx.ref_block_num()).ok_or_else(|| {
201 ProposedBatchError::MissingTransactionReferenceBlock {
202 transaction_id: tx.id(),
203 block_num: tx.ref_block_num(),
204 }
205 })?;
206
207 if block_header.commitment() != tx.ref_block_commitment() {
208 return Err(ProposedBatchError::TransactionReferenceBlockCommitmentMismatch {
209 transaction_id: tx.id(),
210 block_num: tx.ref_block_num(),
211 actual_block_commitment: tx.ref_block_commitment(),
212 expected_block_commitment: block_header.commitment(),
213 });
214 }
215 }
216 }
217
218 // Aggregate individual tx-level account updates into a batch-level account update - one per
219 // account.
220 // --------------------------------------------------------------------------------------------
221
222 // Populate batch output notes and updated accounts.
223 let mut account_updates = BTreeMap::<AccountId, BatchAccountUpdate>::new();
224 for tx in transactions.iter() {
225 // Merge account updates so that state transitions A->B->C become A->C.
226 match account_updates.entry(tx.account_id()) {
227 Entry::Vacant(vacant) => {
228 let batch_account_update = BatchAccountUpdate::from_transaction(tx);
229 vacant.insert(batch_account_update);
230 },
231 Entry::Occupied(occupied) => {
232 // This returns an error if the transactions are not correctly ordered, e.g. if
233 // B comes before A.
234 occupied.into_mut().merge_proven_tx(tx).map_err(|source| {
235 ProposedBatchError::AccountUpdateError {
236 account_id: tx.account_id(),
237 source,
238 }
239 })?;
240 },
241 };
242 }
243
244 if account_updates.len() > MAX_ACCOUNTS_PER_BATCH {
245 return Err(ProposedBatchError::TooManyAccountUpdates(account_updates.len()));
246 }
247
248 // Check that all transaction's expiration block numbers are greater than the reference
249 // block.
250 // --------------------------------------------------------------------------------------------
251
252 let mut batch_expiration_block_num = BlockNumber::from(u32::MAX);
253 for tx in transactions.iter() {
254 if tx.expiration_block_num() <= reference_block_header.block_num() {
255 return Err(ProposedBatchError::ExpiredTransaction {
256 transaction_id: tx.id(),
257 transaction_expiration_num: tx.expiration_block_num(),
258 reference_block_num: reference_block_header.block_num(),
259 });
260 }
261
262 // The expiration block of the batch is the minimum of all transaction's expiration
263 // block.
264 batch_expiration_block_num = batch_expiration_block_num.min(tx.expiration_block_num());
265 }
266
267 // Check for duplicates in input notes.
268 // --------------------------------------------------------------------------------------------
269
270 // Check for duplicate input notes both within a transaction and across transactions.
271 // This also includes authenticated notes, as the transaction kernel doesn't check for
272 // duplicates.
273 let mut input_note_map = BTreeMap::new();
274
275 for tx in transactions.iter() {
276 for note in tx.input_notes() {
277 let nullifier = note.nullifier();
278 if let Some(first_transaction_id) = input_note_map.insert(nullifier, tx.id()) {
279 return Err(ProposedBatchError::DuplicateInputNote {
280 note_nullifier: nullifier,
281 first_transaction_id,
282 second_transaction_id: tx.id(),
283 });
284 }
285 }
286 }
287
288 // Create input and output note set of the batch.
289 // --------------------------------------------------------------------------------------------
290
291 // Check for duplicate output notes and remove all output notes from the batch output note
292 // set that are consumed by transactions.
293 let (input_notes, output_notes) = InputOutputNoteTracker::from_transactions(
294 transactions.iter().map(AsRef::as_ref),
295 &unauthenticated_note_proofs,
296 &partial_blockchain,
297 &reference_block_header,
298 )?;
299
300 if input_notes.len() > MAX_INPUT_NOTES_PER_BATCH {
301 return Err(ProposedBatchError::TooManyInputNotes(input_notes.len()));
302 }
303 // SAFETY: This is safe as we have checked for duplicates and the max number of input notes
304 // in a batch.
305 let input_notes = InputNotes::new_unchecked(input_notes);
306
307 if output_notes.len() > MAX_OUTPUT_NOTES_PER_BATCH {
308 return Err(ProposedBatchError::TooManyOutputNotes(output_notes.len()));
309 }
310
311 // Compute batch ID.
312 // --------------------------------------------------------------------------------------------
313
314 let id = BatchId::from_transactions(transactions.iter().map(AsRef::as_ref));
315
316 Ok(Self {
317 id,
318 transactions,
319 reference_block_header,
320 partial_blockchain,
321 unauthenticated_note_proofs,
322 account_updates,
323 batch_expiration_block_num,
324 input_notes,
325 output_notes,
326 })
327 }
328
329 // PUBLIC ACCESSORS
330 // --------------------------------------------------------------------------------------------
331
332 /// Returns a slice of the [`ProvenTransaction`]s in the batch.
333 pub fn transactions(&self) -> &[Arc<ProvenTransaction>] {
334 &self.transactions
335 }
336
337 /// Returns the ordered set of transactions in the batch.
338 pub fn transaction_headers(&self) -> OrderedTransactionHeaders {
339 // SAFETY: This constructs an ordered set in the order of the transactions in the batch.
340 OrderedTransactionHeaders::new_unchecked(
341 self.transactions
342 .iter()
343 .map(AsRef::as_ref)
344 .map(TransactionHeader::from)
345 .collect(),
346 )
347 }
348
349 /// Returns the map of account IDs mapped to their [`BatchAccountUpdate`]s.
350 ///
351 /// If an account was updated by multiple transactions, the [`BatchAccountUpdate`] is the result
352 /// of merging the individual updates.
353 ///
354 /// For example, suppose an account's state before this batch is `A` and the batch contains two
355 /// transactions that updated it. Applying the first transaction results in intermediate state
356 /// `B`, and applying the second one results in state `C`. Then the returned update represents
357 /// the state transition from `A` to `C`.
358 pub fn account_updates(&self) -> &BTreeMap<AccountId, BatchAccountUpdate> {
359 &self.account_updates
360 }
361
362 /// The ID of this batch. See [`BatchId`] for details on how it is computed.
363 pub fn id(&self) -> BatchId {
364 self.id
365 }
366
367 /// Returns the block number at which the batch will expire.
368 pub fn batch_expiration_block_num(&self) -> BlockNumber {
369 self.batch_expiration_block_num
370 }
371
372 /// Returns the [`InputNotes`] of this batch.
373 pub fn input_notes(&self) -> &InputNotes<InputNoteCommitment> {
374 &self.input_notes
375 }
376
377 /// Returns the output notes of the batch.
378 ///
379 /// This is the aggregation of all output notes by the transactions in the batch, except the
380 /// ones that were consumed within the batch itself.
381 pub fn output_notes(&self) -> &[OutputNote] {
382 &self.output_notes
383 }
384
385 /// Consumes the proposed batch and returns its underlying parts.
386 #[allow(clippy::type_complexity)]
387 pub fn into_parts(
388 self,
389 ) -> (
390 Vec<Arc<ProvenTransaction>>,
391 BlockHeader,
392 PartialBlockchain,
393 BTreeMap<NoteId, NoteInclusionProof>,
394 BatchId,
395 BTreeMap<AccountId, BatchAccountUpdate>,
396 InputNotes<InputNoteCommitment>,
397 Vec<OutputNote>,
398 BlockNumber,
399 ) {
400 (
401 self.transactions,
402 self.reference_block_header,
403 self.partial_blockchain,
404 self.unauthenticated_note_proofs,
405 self.id,
406 self.account_updates,
407 self.input_notes,
408 self.output_notes,
409 self.batch_expiration_block_num,
410 )
411 }
412}
413
414// SERIALIZATION
415// ================================================================================================
416
417impl Serializable for ProposedBatch {
418 fn write_into<W: ByteWriter>(&self, target: &mut W) {
419 self.transactions
420 .iter()
421 .map(|tx| tx.as_ref().clone())
422 .collect::<Vec<ProvenTransaction>>()
423 .write_into(target);
424
425 self.reference_block_header.write_into(target);
426 self.partial_blockchain.write_into(target);
427 self.unauthenticated_note_proofs.write_into(target);
428 }
429}
430
431impl Deserializable for ProposedBatch {
432 fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
433 let transactions = Vec::<ProvenTransaction>::read_from(source)?
434 .into_iter()
435 .map(Arc::new)
436 .collect::<Vec<Arc<ProvenTransaction>>>();
437
438 let block_header = BlockHeader::read_from(source)?;
439 let partial_blockchain = PartialBlockchain::read_from(source)?;
440 let unauthenticated_note_proofs =
441 BTreeMap::<NoteId, NoteInclusionProof>::read_from(source)?;
442
443 ProposedBatch::new(
444 transactions,
445 block_header,
446 partial_blockchain,
447 unauthenticated_note_proofs,
448 )
449 .map_err(|source| {
450 DeserializationError::UnknownError(format!("failed to create proposed batch: {source}"))
451 })
452 }
453}
454
455#[cfg(test)]
456mod tests {
457 use anyhow::Context;
458 use miden_crypto::merkle::mmr::{Mmr, PartialMmr};
459 use miden_crypto::rand::test_utils::rand_value;
460 use miden_verifier::ExecutionProof;
461
462 use super::*;
463 use crate::Word;
464 use crate::account::delta::AccountUpdateDetails;
465 use crate::account::{AccountIdVersion, AccountType};
466 use crate::asset::FungibleAsset;
467 use crate::transaction::{InputNoteCommitment, OutputNote, ProvenTransaction, TxAccountUpdate};
468
469 #[test]
470 fn proposed_batch_serialization() -> anyhow::Result<()> {
471 // create partial blockchain with 3 blocks - i.e., 2 peaks
472 let mut mmr = Mmr::default();
473 for i in 0..3 {
474 let block_header = BlockHeader::mock(i, None, None, &[], Word::empty());
475 mmr.add(block_header.commitment())
476 .expect("mmr leaf count exceeds forest leaf bound");
477 }
478 let partial_mmr: PartialMmr = mmr.peaks().into();
479 let partial_blockchain = PartialBlockchain::new(partial_mmr, Vec::new()).unwrap();
480
481 let chain_commitment = partial_blockchain.peaks().hash_peaks();
482 let note_root = rand_value::<Word>();
483 let tx_kernel_commitment = rand_value::<Word>();
484 let reference_block_header = BlockHeader::mock(
485 3,
486 Some(chain_commitment),
487 Some(note_root),
488 &[],
489 tx_kernel_commitment,
490 );
491
492 let account_id =
493 AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private);
494 let initial_account_commitment =
495 [2; 32].try_into().expect("failed to create initial account commitment");
496 let final_account_commitment =
497 [3; 32].try_into().expect("failed to create final account commitment");
498 let account_delta_commitment =
499 [4; 32].try_into().expect("failed to create account delta commitment");
500 let block_num = reference_block_header.block_num();
501 let block_ref = reference_block_header.commitment();
502 let expiration_block_num = reference_block_header.block_num() + 1;
503 let proof = ExecutionProof::new_dummy();
504
505 let account_update = TxAccountUpdate::new(
506 account_id,
507 initial_account_commitment,
508 final_account_commitment,
509 account_delta_commitment,
510 AccountUpdateDetails::Private,
511 )
512 .context("failed to build account update")?;
513
514 let tx = ProvenTransaction::new(
515 account_update,
516 Vec::<InputNoteCommitment>::new(),
517 Vec::<OutputNote>::new(),
518 block_num,
519 block_ref,
520 FungibleAsset::mock(100).unwrap_fungible(),
521 expiration_block_num,
522 proof,
523 )
524 .context("failed to build proven transaction")?;
525
526 let batch = ProposedBatch::new(
527 vec![Arc::new(tx)],
528 reference_block_header,
529 partial_blockchain,
530 BTreeMap::new(),
531 )
532 .context("failed to propose batch")?;
533
534 let encoded_batch = batch.to_bytes();
535
536 let batch2 = ProposedBatch::read_from_bytes(&encoded_batch)
537 .context("failed to deserialize proposed batch")?;
538
539 assert_eq!(batch.transactions(), batch2.transactions());
540 assert_eq!(batch.reference_block_header, batch2.reference_block_header);
541 assert_eq!(batch.partial_blockchain, batch2.partial_blockchain);
542 assert_eq!(batch.unauthenticated_note_proofs, batch2.unauthenticated_note_proofs);
543 assert_eq!(batch.id, batch2.id);
544 assert_eq!(batch.account_updates, batch2.account_updates);
545 assert_eq!(batch.batch_expiration_block_num, batch2.batch_expiration_block_num);
546 assert_eq!(batch.input_notes, batch2.input_notes);
547 assert_eq!(batch.output_notes, batch2.output_notes);
548
549 Ok(())
550 }
551}