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