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