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}