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