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}