Skip to main content

miden_client/transaction/batch/
mod.rs

1//! Stacks multiple transactions across one or more local accounts and submits them as one
2//! proven batch via the node's `SubmitProvenBatch` endpoint.
3//!
4//! ## Flow
5//!
6//! 1. Open a builder with [`Client::new_transaction_batch`](crate::Client::new_transaction_batch).
7//! 2. Add transactions via [`BatchBuilder::push`]. The first push targeting an account lazily loads
8//!    its current state from the store; later pushes for that same account see the post-state of
9//!    the previous push.
10//! 3. Finalize with [`BatchBuilder::submit`]. This assembles a `ProposedBatch`, proves it, submits
11//!    it to the node, and atomically applies the per-transaction updates to the local store.
12//!
13//! ## Multi-account semantics
14//!
15//! Each `push` specifies which local account the transaction targets. A single batch can
16//! contain transactions from any combination of local accounts. Per-account in-memory state
17//! stacks for repeated pushes against the same account.
18//!
19//! ## In-batch cross-account note flow
20//!
21//! A transaction in the batch may consume a note produced by an earlier transaction in the
22//! same batch — even if the producer and consumer target different accounts. The user
23//! extracts the expected output note from the producing request via
24//! [`TransactionRequest::expected_output_own_notes`] and feeds it as an input to the
25//! consuming request. Push order must respect producer-before-consumer.
26//!
27//! ## Constraints
28//!
29//! - All accounts pushed into the batch must be tracked by the client's store (otherwise the first
30//!   push for that account fails with [`crate::ClientError::AccountDataNotFound`]).
31//! - Locked accounts are rejected with [`crate::ClientError::AccountLocked`].
32//! - No two transactions in a batch may consume the same input note (rejected with
33//!   [`BatchBuilderError::DuplicateInputNote`]).
34//! - A failed [`push`](BatchBuilder::push) leaves the batch exactly as it was, so the caller may
35//!   retry with a different request or submit the transactions accumulated so far.
36//!
37//! ## Error semantics after RPC accept
38//!
39//! Once the node accepts the batch, the local store still needs to be updated. If that step
40//! fails, the caller receives one of two errors that both carry the accepted `block_num`:
41//!
42//! - [`BatchBuilderError::BatchSubmittedButUpdateBuildFailed`] — building one of the per-tx
43//!   [`TransactionStoreUpdate`]s failed.
44//! - [`BatchBuilderError::BatchSubmittedButApplyFailed`] — applying the updates atomically to the
45//!   local store failed.
46//!
47//! In both cases the recovery path is to trigger `sync_state` to reconcile.
48
49mod data_store;
50mod error;
51mod staged_smt;
52
53use alloc::boxed::Box;
54use alloc::collections::{BTreeMap, BTreeSet};
55use alloc::sync::Arc;
56use alloc::vec::Vec;
57
58pub(crate) use data_store::InMemoryBatchDataStore;
59pub use error::BatchBuilderError;
60use miden_protocol::MIN_PROOF_SECURITY_LEVEL;
61use miden_protocol::account::AccountId;
62use miden_protocol::batch::ProposedBatch;
63use miden_protocol::block::{BlockHeader, BlockNumber};
64use miden_protocol::note::NoteId;
65use miden_protocol::transaction::{PartialBlockchain, ProvenTransaction};
66use miden_tx::auth::TransactionAuthenticator;
67use miden_tx_batch::{BatchExecutor, LocalBatchProver};
68
69use crate::rpc::encryption::seal_transaction_inputs;
70use crate::store::data_store::build_partial_mmr_with_paths;
71use crate::transaction::{
72    TransactionRequest,
73    TransactionResult,
74    TransactionStoreUpdate,
75    validate_executed_transaction,
76};
77use crate::{Client, ClientError};
78
79/// A transaction successfully pushed into a [`BatchBuilder`]: the locally-proven transaction
80/// alongside the [`TransactionResult`] used to build the per-tx [`TransactionStoreUpdate`]. The
81/// transaction inputs the RPC submission seals are read back from the result.
82pub(crate) struct PushedTx {
83    pub(crate) proven_tx: Arc<ProvenTransaction>,
84    pub(crate) tx_result: TransactionResult,
85}
86
87/// Accumulates transactions from one or more local accounts and submits them as one proven
88/// batch via the node's `SubmitProvenBatch` endpoint. See the module-level docs for the full
89/// usage and error semantics.
90pub struct BatchBuilder<'c, AUTH> {
91    pub(crate) client: &'c mut Client<AUTH>,
92    pub(crate) data_store: InMemoryBatchDataStore,
93    pub(crate) pushed_txs: Vec<PushedTx>,
94    pub(crate) consumed_input_notes: BTreeSet<NoteId>,
95}
96
97impl<AUTH> BatchBuilder<'_, AUTH> {
98    /// Number of successfully-pushed transactions in this batch.
99    pub fn len(&self) -> usize {
100        self.pushed_txs.len()
101    }
102
103    /// True if no transaction has been pushed yet.
104    pub fn is_empty(&self) -> bool {
105        self.pushed_txs.is_empty()
106    }
107}
108
109impl<AUTH> BatchBuilder<'_, AUTH>
110where
111    AUTH: TransactionAuthenticator + Sync + 'static,
112{
113    /// Assemble the `ProposedBatch`, prove it, submit it via the client's RPC, and
114    /// atomically apply the per-transaction updates to the local store.
115    ///
116    /// Returns the node's chain tip at submission (not the block the batch is committed). The
117    /// submitted transactions are recorded locally as pending; call `sync_state` to get the block
118    /// they commit in.
119    pub async fn submit(self) -> Result<BlockNumber, ClientError> {
120        // 1. Treat the largest ref as the reference block and the rest as authenticated. An empty
121        //    batch surfaces here as a missing max.
122        let ref_block_num = self
123            .pushed_txs
124            .iter()
125            .map(|p| p.proven_tx.ref_block_num())
126            .max()
127            .ok_or(BatchBuilderError::Empty)?;
128
129        let lower_refs: BTreeSet<BlockNumber> = self
130            .pushed_txs
131            .iter()
132            .map(|p| p.proven_tx.ref_block_num())
133            .filter(|&r| r < ref_block_num)
134            .collect();
135
136        let store = self.client.store.clone();
137
138        // 2. Fetch the reference block header (from the store).
139        let (ref_block_header, _) = store
140            .get_block_header_by_num(ref_block_num)
141            .await
142            .map_err(ClientError::StoreError)?
143            .ok_or_else(|| {
144                ClientError::StoreError(crate::store::StoreError::BlockHeaderNotFound(
145                    ref_block_num,
146                ))
147            })?;
148
149        // 3. Fetch block headers for each lower ref (the ones needing authentication).
150        let fetched =
151            store.get_block_headers(&lower_refs).await.map_err(ClientError::StoreError)?;
152        let authenticated_blocks: Vec<BlockHeader> =
153            fetched.into_iter().map(|(header, _)| header).collect();
154        let fetched_nums: BTreeSet<BlockNumber> =
155            authenticated_blocks.iter().map(BlockHeader::block_num).collect();
156        if let Some(&missing) = lower_refs.difference(&fetched_nums).next() {
157            return Err(ClientError::StoreError(crate::store::StoreError::BlockHeaderNotFound(
158                missing,
159            )));
160        }
161
162        // 4. Build PartialMmr + PartialBlockchain using the current blockchain peaks — this matches
163        //    the MMR convention used by `ClientDataStore::get_transaction_inputs`.
164        let current_peaks =
165            store.get_current_blockchain_peaks().await.map_err(ClientError::StoreError)?;
166        let partial_mmr =
167            build_partial_mmr_with_paths(&store, current_peaks, &authenticated_blocks).await?;
168        let partial_blockchain = PartialBlockchain::new(partial_mmr, authenticated_blocks)?;
169
170        // 5. Split pushed_txs into the two views required by the remaining steps and build the
171        //    ProposedBatch.
172        let len = self.pushed_txs.len();
173        let mut proven_txs: Vec<Arc<ProvenTransaction>> = Vec::with_capacity(len);
174        let mut tx_results: Vec<TransactionResult> = Vec::with_capacity(len);
175        for pushed in self.pushed_txs {
176            proven_txs.push(pushed.proven_tx);
177            tx_results.push(pushed.tx_result);
178        }
179
180        // TODO: field is left unused as of now because all txs in batch are already proven.
181        // This will be populated once a feature like remote proving in batches is implemented.
182        let unauthenticated_note_proofs = BTreeMap::new();
183        let proposed_batch = ProposedBatch::new(
184            proven_txs,
185            ref_block_header,
186            partial_blockchain,
187            unauthenticated_note_proofs,
188            MIN_PROOF_SECURITY_LEVEL,
189        )?;
190
191        // 6. Execute the batch kernel, then prove synchronously.
192        let executed_batch = BatchExecutor::new().execute(proposed_batch.clone())?;
193        let proven_batch = LocalBatchProver::new().prove(executed_batch)?;
194
195        // 7. Seal each transaction's inputs, then submit via RPC. Each entry is sealed against its
196        //    own transaction id.
197        let key = self.client.transaction_encryption_key().await?;
198        let sealed_inputs = tx_results
199            .iter()
200            .map(|tx_result| {
201                let executed = tx_result.executed_transaction();
202                seal_transaction_inputs(
203                    &mut self.client.rng,
204                    &key,
205                    executed.id(),
206                    executed.tx_inputs(),
207                )
208            })
209            .collect::<Result<Vec<_>, _>>()?;
210
211        let mut updates: Vec<TransactionStoreUpdate> = Vec::with_capacity(len);
212        let result = self
213            .client
214            .rpc_api
215            .submit_proven_batch(proven_batch, proposed_batch, sealed_inputs)
216            .await;
217        if let Err(err) = &result {
218            self.client.forget_stale_transaction_encryption_key(err).await;
219        }
220        let block_num = result?;
221
222        // 8. Build per-tx TransactionStoreUpdates.
223        for tx_result in &tx_results {
224            let update =
225                self.client.get_transaction_store_update(tx_result, block_num).await.map_err(
226                    |source| BatchBuilderError::BatchSubmittedButUpdateBuildFailed {
227                        block_num,
228                        source,
229                    },
230                )?;
231            updates.push(update);
232        }
233
234        // 9. Apply atomically; if it fails, return BatchSubmittedButApplyFailed.
235        if let Err(source) = self.client.store.apply_transaction_batch(updates).await {
236            return Err(ClientError::from(BatchBuilderError::BatchSubmittedButApplyFailed {
237                block_num,
238                source,
239            }));
240        }
241
242        Ok(block_num)
243    }
244
245    /// Execute `req` against the batch's in-memory state for `account_id`, prove it using
246    /// the client's configured prover, and append the resulting proven transaction to the
247    /// batch. The first push for a given account lazily loads its state from the store.
248    ///
249    /// The batch is only advanced once the transaction has both executed and been proven, so on
250    /// failure the builder still holds exactly the transactions it held before the call and
251    /// remains usable. Returns `&mut Self` so pushes can be chained.
252    pub async fn push(
253        &mut self,
254        account_id: AccountId,
255        req: TransactionRequest,
256    ) -> Result<&mut Self, ClientError> {
257        // 1. Dedup input notes globally for the batch.
258        for note_id in req.input_note_ids() {
259            if self.consumed_input_notes.contains(&note_id) {
260                return Err(ClientError::from(BatchBuilderError::DuplicateInputNote(note_id)));
261            }
262        }
263
264        // 2. Execute against in-batch state, then prove. Both run before any batch state is
265        //    advanced, so a failure in either leaves the builder untouched. Execution holds a large
266        //    future, boxed here so callers don't have to.
267        let tx_result =
268            Box::pin(execute_transaction_for_batch(self.client, &self.data_store, account_id, req))
269                .await?;
270        let proven_tx = self.client.prove_transaction(&tx_result).await?;
271
272        // 3. The transaction is final: fold it into the in-batch account state, record its consumed
273        //    notes, and append it to the batch.
274        self.data_store
275            .apply_executed_transaction(tx_result.executed_transaction())
276            .await?;
277        for note in tx_result.consumed_notes().iter() {
278            self.consumed_input_notes.insert(note.id());
279        }
280        self.pushed_txs.push(PushedTx {
281            proven_tx: Arc::new(proven_tx),
282            tx_result,
283        });
284        Ok(self)
285    }
286}
287
288/// Executes a single transaction that is part of the batch to be sent to the node.
289/// The transaction runs against the current in-batch partial account state.
290async fn execute_transaction_for_batch<AUTH>(
291    client: &Client<AUTH>,
292    data_store: &InMemoryBatchDataStore,
293    account_id: AccountId,
294    transaction_request: TransactionRequest,
295) -> Result<TransactionResult, ClientError>
296where
297    AUTH: TransactionAuthenticator + Sync + 'static,
298{
299    let account_reader = client.account_reader(account_id);
300    if account_reader.status().await?.is_locked() {
301        return Err(ClientError::AccountLocked(account_id));
302    }
303
304    let account = match data_store.cached_account(account_id) {
305        Some(account) => account,
306        None => account_reader.partial_account().await?,
307    };
308
309    let prep = client
310        .prepare_transaction(account.code_interface(), transaction_request, None)
311        .await?;
312
313    data_store.register_note_scripts(prep.output_note_scripts());
314    for fpi_account in &prep.foreign_account_inputs {
315        data_store.mast_store().load_account_code(fpi_account.code());
316    }
317    data_store.register_foreign_account_inputs(prep.foreign_account_inputs);
318
319    data_store.mast_store().load_account_code(account.code());
320
321    let mut notes = prep.notes;
322    if prep.ignore_invalid_notes {
323        notes = client
324            .get_valid_input_notes(
325                data_store,
326                account_id,
327                prep.block_num,
328                notes,
329                prep.tx_args.clone(),
330            )
331            .await?;
332    }
333
334    let executed_transaction = client
335        .build_executor(data_store)?
336        .execute_transaction(account_id, prep.block_num, notes, prep.tx_args)
337        .await?;
338
339    validate_executed_transaction(&executed_transaction, &prep.output_recipients)?;
340    TransactionResult::new(executed_transaction, prep.future_notes)
341}