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//! ## Error semantics after RPC accept
38//!
39//! Once the node accepts the batch, the local store still needs to be updated. If that step fails,
40//! 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 batch
88/// via the node's `SubmitProvenBatch` endpoint. See the module-level docs for the full usage and
89/// 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 atomically apply
114    /// 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. This
181        // 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 =
194            LocalBatchProver::new(miden_tx::Prover::default()).prove(executed_batch)?;
195
196        // 7. Seal each transaction's inputs, then submit via RPC. Each entry is sealed against its
197        //    own transaction id.
198        let key = self.client.transaction_encryption_key().await?;
199        let sealed_inputs = tx_results
200            .iter()
201            .map(|tx_result| {
202                let executed = tx_result.executed_transaction();
203                seal_transaction_inputs(
204                    &mut self.client.rng,
205                    &key,
206                    executed.id(),
207                    executed.tx_inputs(),
208                )
209            })
210            .collect::<Result<Vec<_>, _>>()?;
211
212        let mut updates: Vec<TransactionStoreUpdate> = Vec::with_capacity(len);
213        let result = self
214            .client
215            .rpc_api
216            .submit_proven_batch(proven_batch, proposed_batch, sealed_inputs)
217            .await;
218        if let Err(err) = &result {
219            self.client.forget_stale_transaction_encryption_key(err).await;
220        }
221        let block_num = result?;
222
223        // 8. Build per-tx TransactionStoreUpdates.
224        for tx_result in &tx_results {
225            let update =
226                self.client.get_transaction_store_update(tx_result, block_num).await.map_err(
227                    |source| BatchBuilderError::BatchSubmittedButUpdateBuildFailed {
228                        block_num,
229                        source,
230                    },
231                )?;
232            updates.push(update);
233        }
234
235        // 9. Apply atomically; if it fails, return BatchSubmittedButApplyFailed.
236        if let Err(source) = self.client.store.apply_transaction_batch(updates).await {
237            return Err(ClientError::from(BatchBuilderError::BatchSubmittedButApplyFailed {
238                block_num,
239                source,
240            }));
241        }
242
243        Ok(block_num)
244    }
245
246    /// Execute `req` against the batch's in-memory state for `account_id`, prove it using the
247    /// client's configured prover, and append the resulting proven transaction to the batch. The
248    /// first push for a given account lazily loads its state from the store.
249    ///
250    /// The batch is only advanced once the transaction has both executed and been proven, so on
251    /// failure the builder still holds exactly the transactions it held before the call and remains
252    /// usable. Returns `&mut Self` so pushes can be chained.
253    pub async fn push(
254        &mut self,
255        account_id: AccountId,
256        req: TransactionRequest,
257    ) -> Result<&mut Self, ClientError> {
258        // 1. Dedup input notes globally for the batch.
259        for note_id in req.input_note_ids() {
260            if self.consumed_input_notes.contains(&note_id) {
261                return Err(ClientError::from(BatchBuilderError::DuplicateInputNote(note_id)));
262            }
263        }
264
265        // 2. Execute against in-batch state, then prove. Both run before any batch state is
266        //    advanced, so a failure in either leaves the builder untouched. Execution holds a large
267        //    future, boxed here so callers don't have to.
268        let tx_result =
269            Box::pin(execute_transaction_for_batch(self.client, &self.data_store, account_id, req))
270                .await?;
271        let proven_tx = self.client.prove_transaction(&tx_result).await?;
272
273        // 3. The transaction is final: fold it into the in-batch account state, record its consumed
274        //    notes, and append it to the batch.
275        self.data_store
276            .apply_executed_transaction(tx_result.executed_transaction())
277            .await?;
278        for note in tx_result.consumed_notes().iter() {
279            self.consumed_input_notes.insert(note.id());
280        }
281        self.pushed_txs.push(PushedTx {
282            proven_tx: Arc::new(proven_tx),
283            tx_result,
284        });
285        Ok(self)
286    }
287}
288
289/// Executes a single transaction that is part of the batch to be sent to the node. The transaction
290/// runs against the current in-batch partial account state.
291async fn execute_transaction_for_batch<AUTH>(
292    client: &Client<AUTH>,
293    data_store: &InMemoryBatchDataStore,
294    account_id: AccountId,
295    transaction_request: TransactionRequest,
296) -> Result<TransactionResult, ClientError>
297where
298    AUTH: TransactionAuthenticator + Sync + 'static,
299{
300    let account_reader = client.account_reader(account_id);
301    if account_reader.status().await?.is_locked() {
302        return Err(ClientError::AccountLocked(account_id));
303    }
304
305    let account = match data_store.cached_account(account_id) {
306        Some(account) => account,
307        None => account_reader.partial_account().await?,
308    };
309
310    let prep = client.prepare_transaction_for_batch(&account, transaction_request).await?;
311
312    data_store.register_note_scripts(prep.output_note_scripts());
313    for fpi_account in &prep.foreign_account_inputs {
314        data_store.mast_store().load_account_code(fpi_account.code());
315    }
316    data_store.register_foreign_account_inputs(prep.foreign_account_inputs);
317
318    data_store.mast_store().load_account_code(account.code());
319
320    let mut notes = prep.notes;
321    if prep.ignore_invalid_notes {
322        notes = client
323            .get_valid_input_notes(
324                data_store,
325                account_id,
326                prep.block_num,
327                notes,
328                prep.tx_args.clone(),
329            )
330            .await?;
331    }
332
333    let executed_transaction = client
334        .build_executor(data_store)?
335        .execute_transaction(account_id, prep.block_num, notes, prep.tx_args)
336        .await?;
337
338    validate_executed_transaction(&executed_transaction, &prep.output_recipients)?;
339    TransactionResult::new(executed_transaction, prep.future_notes)
340}