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