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