Skip to main content

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}