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 proven
2//! 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 contain
16//! transactions from any combination of local accounts. Per-account in-memory state stacks for
17//! 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 same
22//! batch — even if the producer and consumer target different accounts. The user extracts the
23//! expected output note from the producing request via
24//! [`TransactionRequest::expected_output_own_notes`] and feeds it as an input to the consuming
25//! 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//! ## Account allowlist
38//!
39//! [`BatchBuilder::submit`] asks the network allowlist about each account that the batch creates
40//! before the batch is proven. It fails with [`crate::ClientError::AccountNotAllowlisted`] if the
41//! network does not accept one of them. [`Client::retry_proven_batch`] does not ask again.
42//!
43//! ## Error semantics around submission
44//!
45//! A submission that comes back without a definite outcome raises
46//! [`BatchBuilderError::BatchSubmissionOutcomeUnknown`]. The node may or may not have accepted the
47//! batch and nothing was recorded locally, so the error carries a [`ProvenBatchSubmission`] to
48//! resend with [`Client::retry_proven_batch`].
49//!
50//! Once the node accepts the batch, the local store still needs to be updated. If that step fails,
51//! the caller receives one of two errors that both carry the accepted `block_num`:
52//!
53//! - [`BatchBuilderError::BatchSubmittedButUpdateBuildFailed`] — building one of the per-tx
54//!   [`TransactionStoreUpdate`]s failed.
55//! - [`BatchBuilderError::BatchSubmittedButApplyFailed`] — applying the updates atomically to the
56//!   local store failed.
57//!
58//! In all three cases `sync_state` reconciles the accounts with what the network holds. It does not
59//! create transaction records, though: syncing updates records the client already holds and never
60//! inserts missing ones. For the unknown outcome an accepted retry writes them; for the two
61//! post-accept errors nothing will, since neither carries the updates that failed.
62
63mod data_store;
64mod error;
65mod staged_smt;
66
67use alloc::boxed::Box;
68use alloc::collections::{BTreeMap, BTreeSet};
69use alloc::sync::Arc;
70use alloc::vec::Vec;
71
72pub(crate) use data_store::InMemoryBatchDataStore;
73pub use error::BatchBuilderError;
74use miden_protocol::MIN_PROOF_SECURITY_LEVEL;
75use miden_protocol::account::AccountId;
76use miden_protocol::batch::{ProposedBatch, ProvenBatch};
77use miden_protocol::block::{BlockHeader, BlockNumber};
78use miden_protocol::note::NoteId;
79use miden_protocol::transaction::{PartialBlockchain, ProvenTransaction, TransactionId};
80use miden_tx::auth::TransactionAuthenticator;
81use miden_tx_batch::{BatchExecutor, LocalBatchProver};
82
83use crate::rpc::RpcError;
84use crate::rpc::encryption::seal_transaction_inputs;
85use crate::store::data_store::{ClientDataStore, build_partial_mmr_with_paths};
86use crate::transaction::{
87    TransactionRequest,
88    TransactionResult,
89    TransactionStoreUpdate,
90    ensure_account_allowed,
91    validate_executed_transaction,
92};
93use crate::{Client, ClientError};
94
95/// A proven batch together with everything else its submission needs, so a submission whose outcome
96/// the node never confirmed can be retried without executing or proving again.
97///
98/// Handed back by [`BatchBuilderError::BatchSubmissionOutcomeUnknown`] and accepted by
99/// [`Client::retry_proven_batch`].
100#[derive(Debug, Clone)]
101pub struct ProvenBatchSubmission {
102    proven_batch: ProvenBatch,
103    proposed_batch: Box<ProposedBatch>,
104    /// The validator set's key can rotate between attempts, so a retry has to seal these again, and
105    /// `BatchBuilder::submit` needs the whole results after the RPC for the store updates.
106    tx_results: Vec<TransactionResult>,
107}
108
109impl ProvenBatchSubmission {
110    /// Number of transactions in the batch.
111    pub fn transaction_count(&self) -> usize {
112        self.tx_results.len()
113    }
114
115    /// Ids the batch was submitted with. Nothing is recorded for them yet, so they reach
116    /// `get_transactions` only once a retry is accepted.
117    pub fn transaction_ids(&self) -> impl Iterator<Item = TransactionId> + '_ {
118        self.tx_results.iter().map(|tx_result| tx_result.executed_transaction().id())
119    }
120}
121
122/// A transaction successfully pushed into a [`BatchBuilder`]: the locally-proven transaction
123/// alongside the [`TransactionResult`] used to build the per-tx [`TransactionStoreUpdate`]. The
124/// transaction inputs the RPC submission seals are read back from the result.
125pub(crate) struct PushedTx {
126    pub(crate) proven_tx: Arc<ProvenTransaction>,
127    pub(crate) tx_result: TransactionResult,
128}
129
130/// Accumulates transactions from one or more local accounts and submits them as one proven batch
131/// via the node's `SubmitProvenBatch` endpoint. See the module-level docs for the full usage and
132/// error semantics.
133pub struct BatchBuilder<'c, AUTH> {
134    pub(crate) client: &'c mut Client<AUTH>,
135    pub(crate) data_store: InMemoryBatchDataStore,
136    pub(crate) pushed_txs: Vec<PushedTx>,
137    pub(crate) consumed_input_notes: BTreeSet<NoteId>,
138}
139
140impl<AUTH> BatchBuilder<'_, AUTH> {
141    /// Number of successfully-pushed transactions in this batch.
142    pub fn len(&self) -> usize {
143        self.pushed_txs.len()
144    }
145
146    /// True if no transaction has been pushed yet.
147    pub fn is_empty(&self) -> bool {
148        self.pushed_txs.is_empty()
149    }
150}
151
152impl<AUTH> Client<AUTH>
153where
154    AUTH: TransactionAuthenticator + Sync + 'static,
155{
156    /// Open a new [`BatchBuilder`] for accumulating transactions across one or more local accounts.
157    ///
158    /// See the module-level docs for usage and constraints.
159    pub fn new_transaction_batch(&mut self) -> BatchBuilder<'_, AUTH> {
160        let inner_data_store = ClientDataStore::new(self.store.clone(), self.rpc_api.clone());
161        BatchBuilder {
162            client: self,
163            data_store: InMemoryBatchDataStore::new(inner_data_store),
164            pushed_txs: Vec::new(),
165            consumed_input_notes: BTreeSet::new(),
166        }
167    }
168
169    /// Resubmits an already-proven batch and returns the node's chain tip upon mempool admission.
170    ///
171    /// This is the retry entry point for a submission whose outcome was never confirmed: pass back
172    /// the [`ProvenBatchSubmission`] carried by
173    /// [`BatchBuilderError::BatchSubmissionOutcomeUnknown`] and the batch goes out again without
174    /// being executed or proven a second time. The batch id is fixed, so resending it cannot
175    /// duplicate its effects, but the node rejects it as a conflict if the original did land.
176    ///
177    /// That error is the only source of a [`ProvenBatchSubmission`]: the type has no public
178    /// constructor, and assembling and proving a batch goes through [`BatchBuilder`].
179    ///
180    /// A retry the node accepts records the batch the way the first send would have, so the
181    /// transactions reach the store no matter which attempt landed. A retry the node rejects
182    /// records nothing, and neither will a later sync: syncing updates records the client already
183    /// holds and never inserts missing ones.
184    ///
185    /// # Errors
186    ///
187    /// Returns [`BatchBuilderError::BatchSubmissionOutcomeUnknown`] when the submission comes back
188    /// without a definite answer. Every other failure is a rejection the node issued deliberately.
189    pub async fn retry_proven_batch(
190        &mut self,
191        submission: &ProvenBatchSubmission,
192    ) -> Result<BlockNumber, ClientError> {
193        self.send_and_apply_proven_batch(submission).await
194    }
195
196    /// Seals the submission's inputs against the current key, sends the batch, and on acceptance
197    /// applies the per-transaction store updates atomically.
198    ///
199    /// Shared by the first send from [`BatchBuilder::submit`] and by every retry through
200    /// [`Client::retry_proven_batch`], so both record what the node took and both map an
201    /// unconfirmed outcome to the error that carries the submission back.
202    async fn send_and_apply_proven_batch(
203        &mut self,
204        submission: &ProvenBatchSubmission,
205    ) -> Result<BlockNumber, ClientError> {
206        // Each entry is sealed against its own transaction id, with fresh randomness per attempt.
207        let key = self.transaction_encryption_key().await?;
208        let sealed_inputs = submission
209            .tx_results
210            .iter()
211            .map(|tx_result| {
212                let executed = tx_result.executed_transaction();
213                seal_transaction_inputs(&mut self.rng, &key, executed.id(), executed.tx_inputs())
214            })
215            .collect::<Result<Vec<_>, _>>()?;
216
217        let result = self
218            .rpc_api
219            .submit_proven_batch(
220                &submission.proven_batch,
221                &submission.proposed_batch,
222                sealed_inputs,
223            )
224            .await;
225        if let Err(err) = &result {
226            self.forget_stale_transaction_encryption_key(err).await;
227        }
228
229        let block_num = result.map_err(|err| promote_indeterminate_submission(err, submission))?;
230
231        // The node took the batch. Record it, one update per transaction, applied atomically.
232        let mut updates: Vec<TransactionStoreUpdate> =
233            Vec::with_capacity(submission.transaction_count());
234        for tx_result in &submission.tx_results {
235            let update = self.get_transaction_store_update(tx_result, block_num).await.map_err(
236                |source| BatchBuilderError::BatchSubmittedButUpdateBuildFailed {
237                    block_num,
238                    source,
239                },
240            )?;
241            updates.push(update);
242        }
243
244        if let Err(source) = self.store.apply_transaction_batch(updates).await {
245            return Err(ClientError::from(BatchBuilderError::BatchSubmittedButApplyFailed {
246                block_num,
247                source,
248            }));
249        }
250
251        Ok(block_num)
252    }
253}
254
255impl<AUTH> BatchBuilder<'_, AUTH>
256where
257    AUTH: TransactionAuthenticator + Sync + 'static,
258{
259    /// Assemble the `ProposedBatch`, prove it, submit it via the client's RPC, and atomically apply
260    /// the per-transaction updates to the local store.
261    ///
262    /// Returns the node's chain tip at submission (not the block the batch is committed). The
263    /// submitted transactions are recorded locally as pending; call `sync_state` to get the block
264    /// they commit in.
265    pub async fn submit(self) -> Result<BlockNumber, ClientError> {
266        // 1. Treat the largest ref as the reference block and the rest as authenticated. An empty
267        //    batch surfaces here as a missing max.
268        let ref_block_num = self
269            .pushed_txs
270            .iter()
271            .map(|p| p.proven_tx.ref_block_num())
272            .max()
273            .ok_or(BatchBuilderError::Empty)?;
274
275        let lower_refs: BTreeSet<BlockNumber> = self
276            .pushed_txs
277            .iter()
278            .map(|p| p.proven_tx.ref_block_num())
279            .filter(|&r| r < ref_block_num)
280            .collect();
281
282        // Accounts that the batch creates are gated by the network allowlist. Ask before the batch
283        // is proven.
284        let account_ids: BTreeSet<AccountId> =
285            self.pushed_txs.iter().map(|p| p.proven_tx.account_id()).collect();
286        for account_id in account_ids {
287            if self.client.is_allowlist_gated(account_id).await? {
288                ensure_account_allowed(
289                    account_id,
290                    self.client.is_account_allowed(account_id).await,
291                )?;
292            }
293        }
294
295        let store = self.client.store.clone();
296
297        // 2. Fetch the reference block header (from the store).
298        let (ref_block_header, _) = store
299            .get_block_header_by_num(ref_block_num)
300            .await
301            .map_err(ClientError::StoreError)?
302            .ok_or_else(|| {
303                ClientError::StoreError(crate::store::StoreError::BlockHeaderNotFound(
304                    ref_block_num,
305                ))
306            })?;
307
308        // 3. Fetch block headers for each lower ref (the ones needing authentication).
309        let fetched =
310            store.get_block_headers(&lower_refs).await.map_err(ClientError::StoreError)?;
311        let authenticated_blocks: Vec<BlockHeader> =
312            fetched.into_iter().map(|(header, _)| header).collect();
313        let fetched_nums: BTreeSet<BlockNumber> =
314            authenticated_blocks.iter().map(BlockHeader::block_num).collect();
315        if let Some(&missing) = lower_refs.difference(&fetched_nums).next() {
316            return Err(ClientError::StoreError(crate::store::StoreError::BlockHeaderNotFound(
317                missing,
318            )));
319        }
320
321        // 4. Build PartialMmr + PartialBlockchain using the current blockchain peaks — this matches
322        //    the MMR convention used by `ClientDataStore::get_transaction_inputs`.
323        let current_peaks =
324            store.get_current_blockchain_peaks().await.map_err(ClientError::StoreError)?;
325        let partial_mmr =
326            build_partial_mmr_with_paths(&store, current_peaks, &authenticated_blocks).await?;
327        let partial_blockchain = PartialBlockchain::new(partial_mmr, authenticated_blocks)?;
328
329        // 5. Split pushed_txs into the two views required by the remaining steps and build the
330        //    ProposedBatch.
331        let len = self.pushed_txs.len();
332        let mut proven_txs: Vec<Arc<ProvenTransaction>> = Vec::with_capacity(len);
333        let mut tx_results: Vec<TransactionResult> = Vec::with_capacity(len);
334        for pushed in self.pushed_txs {
335            proven_txs.push(pushed.proven_tx);
336            tx_results.push(pushed.tx_result);
337        }
338
339        // TODO: field is left unused as of now because all txs in batch are already proven. This
340        // will be populated once a feature like remote proving in batches is implemented.
341        let unauthenticated_note_proofs = BTreeMap::new();
342        let proposed_batch = ProposedBatch::new(
343            proven_txs,
344            ref_block_header,
345            partial_blockchain,
346            unauthenticated_note_proofs,
347            MIN_PROOF_SECURITY_LEVEL,
348        )?;
349
350        // 6. Execute the batch kernel, then prove synchronously.
351        let executed_batch = BatchExecutor::new().execute(proposed_batch.clone())?;
352        let proven_batch =
353            LocalBatchProver::new(miden_tx::Prover::default()).prove(executed_batch)?;
354
355        // 7. Submit via RPC and record what the node took. The proven batch is kept so an
356        //    unconfirmed submission can be retried without executing or proving again.
357        let submission = ProvenBatchSubmission {
358            proven_batch,
359            proposed_batch: Box::new(proposed_batch),
360            tx_results,
361        };
362        let block_num = self.client.send_and_apply_proven_batch(&submission).await?;
363
364        Ok(block_num)
365    }
366
367    /// Execute `req` against the batch's in-memory state for `account_id`, prove it using the
368    /// client's configured prover, and append the resulting proven transaction to the batch. The
369    /// first push for a given account lazily loads its state from the store.
370    ///
371    /// The batch is only advanced once the transaction has both executed and been proven, so on
372    /// failure the builder still holds exactly the transactions it held before the call and remains
373    /// usable. Returns `&mut Self` so pushes can be chained.
374    pub async fn push(
375        &mut self,
376        account_id: AccountId,
377        req: TransactionRequest,
378    ) -> Result<&mut Self, ClientError> {
379        // 1. Dedup input notes globally for the batch.
380        for note_id in req.input_note_ids() {
381            if self.consumed_input_notes.contains(&note_id) {
382                return Err(ClientError::from(BatchBuilderError::DuplicateInputNote(note_id)));
383            }
384        }
385
386        // 2. Execute against in-batch state, then prove. Both run before any batch state is
387        //    advanced, so a failure in either leaves the builder untouched. Execution holds a large
388        //    future, boxed here so callers don't have to.
389        let tx_result =
390            Box::pin(execute_transaction_for_batch(self.client, &self.data_store, account_id, req))
391                .await?;
392
393        let proven_tx = self.client.prove_transaction(&tx_result).await?;
394
395        // 3. The transaction is final: fold it into the in-batch account state, record its consumed
396        //    notes, and append it to the batch.
397        self.data_store
398            .apply_executed_transaction(tx_result.executed_transaction())
399            .await?;
400        for note in tx_result.consumed_notes().iter() {
401            self.consumed_input_notes.insert(note.id());
402        }
403        self.pushed_txs.push(PushedTx {
404            proven_tx: Arc::new(proven_tx),
405            tx_result,
406        });
407        Ok(self)
408    }
409}
410
411/// Executes a single transaction that is part of the batch to be sent to the node. The transaction
412/// runs against the current in-batch partial account state.
413async fn execute_transaction_for_batch<AUTH>(
414    client: &Client<AUTH>,
415    data_store: &InMemoryBatchDataStore,
416    account_id: AccountId,
417    transaction_request: TransactionRequest,
418) -> Result<TransactionResult, ClientError>
419where
420    AUTH: TransactionAuthenticator + Sync + 'static,
421{
422    let account_reader = client.account_reader(account_id);
423    if account_reader.status().await?.is_locked() {
424        return Err(ClientError::AccountLocked(account_id));
425    }
426
427    let account = match data_store.cached_account(account_id) {
428        Some(account) => account,
429        None => account_reader.partial_account().await?,
430    };
431
432    let prep = client.prepare_transaction_for_batch(&account, transaction_request).await?;
433
434    data_store.register_note_scripts(prep.output_note_scripts());
435    for fpi_account in &prep.foreign_account_inputs {
436        data_store.mast_store().load_account_code(fpi_account.code());
437    }
438    data_store.register_foreign_account_inputs(prep.foreign_account_inputs);
439
440    data_store.mast_store().load_account_code(account.code());
441
442    let mut notes = prep.notes;
443    if prep.ignore_invalid_notes {
444        notes = client
445            .get_valid_input_notes(
446                data_store,
447                account_id,
448                prep.block_num,
449                notes,
450                prep.tx_args.clone(),
451            )
452            .await?;
453    }
454
455    let executed_transaction = client
456        .build_executor(data_store)?
457        .execute_transaction(account_id, prep.block_num, notes, prep.tx_args)
458        .await?;
459
460    validate_executed_transaction(&executed_transaction, &prep.output_recipients)?;
461    TransactionResult::new(executed_transaction, prep.future_notes)
462}
463
464/// Promotes a batch submission failure whose outcome is unknown, attaching everything a retry
465/// needs. Any other failure is a rejection the node issued deliberately and passes through
466/// unchanged.
467fn promote_indeterminate_submission(
468    err: RpcError,
469    submission: &ProvenBatchSubmission,
470) -> ClientError {
471    if !err.is_indeterminate_submission() {
472        return ClientError::RpcError(err);
473    }
474
475    BatchBuilderError::BatchSubmissionOutcomeUnknown {
476        submission: Box::new(submission.clone()),
477        source: err,
478    }
479    .into()
480}