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//! - The builder is succeed-only: every transaction must be pushed successfully for the batch to
35//!   reach [`submit`](BatchBuilder::submit).
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;
51
52use alloc::collections::{BTreeMap, BTreeSet};
53use alloc::sync::Arc;
54use alloc::vec::Vec;
55
56pub(crate) use data_store::InMemoryBatchDataStore;
57pub use error::BatchBuilderError;
58use miden_protocol::MIN_PROOF_SECURITY_LEVEL;
59use miden_protocol::account::{Account, AccountId};
60use miden_protocol::batch::ProposedBatch;
61use miden_protocol::block::{BlockHeader, BlockNumber};
62use miden_protocol::note::NoteId;
63use miden_protocol::transaction::{PartialBlockchain, ProvenTransaction, TransactionInputs};
64use miden_tx::auth::TransactionAuthenticator;
65use miden_tx_batch::{BatchExecutor, LocalBatchProver};
66
67use crate::store::data_store::build_partial_mmr_with_paths;
68use crate::transaction::{
69    TransactionRequest,
70    TransactionResult,
71    TransactionStoreUpdate,
72    validate_executed_transaction,
73};
74use crate::{Client, ClientError};
75
76/// A transaction successfully pushed into a [`BatchBuilder`]: bundles the locally-proven
77/// transaction with the [`TransactionInputs`] needed by the RPC submission and the
78/// [`TransactionResult`] used to build the per-tx [`TransactionStoreUpdate`].
79pub(crate) struct PushedTx {
80    pub(crate) proven_tx: Arc<ProvenTransaction>,
81    pub(crate) transaction_inputs: TransactionInputs,
82    pub(crate) tx_result: TransactionResult,
83}
84
85/// Accumulates transactions from one or more local accounts and submits them as one proven
86/// batch via the node's `SubmitProvenBatch` endpoint. See the module-level docs for the full
87/// usage and error semantics.
88pub struct BatchBuilder<'c, AUTH> {
89    pub(crate) client: &'c Client<AUTH>,
90    pub(crate) data_store: InMemoryBatchDataStore,
91    pub(crate) pushed_txs: Vec<PushedTx>,
92    pub(crate) consumed_input_notes: BTreeSet<NoteId>,
93}
94
95impl<AUTH> BatchBuilder<'_, AUTH> {
96    /// Number of successfully-pushed transactions in this batch.
97    pub fn len(&self) -> usize {
98        self.pushed_txs.len()
99    }
100
101    /// True if no transaction has been pushed yet.
102    pub fn is_empty(&self) -> bool {
103        self.pushed_txs.is_empty()
104    }
105}
106
107impl<AUTH> BatchBuilder<'_, AUTH>
108where
109    AUTH: TransactionAuthenticator + Sync + 'static,
110{
111    /// Assemble the `ProposedBatch`, prove it, submit it via the client's RPC, and
112    /// atomically apply the per-transaction updates to the local store.
113    ///
114    /// Returns the node's chain tip at submission (not the block the batch is committed). The
115    /// submitted transactions are recorded locally as pending; call `sync_state` to get the block
116    /// they commit in.
117    pub async fn submit(self) -> Result<BlockNumber, ClientError> {
118        // 1. Treat the largest ref as the reference block and the rest as authenticated. An empty
119        //    batch surfaces here as a missing max.
120        let ref_block_num = self
121            .pushed_txs
122            .iter()
123            .map(|p| p.proven_tx.ref_block_num())
124            .max()
125            .ok_or(BatchBuilderError::Empty)?;
126
127        let lower_refs: BTreeSet<BlockNumber> = self
128            .pushed_txs
129            .iter()
130            .map(|p| p.proven_tx.ref_block_num())
131            .filter(|&r| r < ref_block_num)
132            .collect();
133
134        let store = self.client.store.clone();
135
136        // 2. Fetch the reference block header (from the store).
137        let (ref_block_header, _) = store
138            .get_block_header_by_num(ref_block_num)
139            .await
140            .map_err(ClientError::StoreError)?
141            .ok_or_else(|| {
142                ClientError::StoreError(crate::store::StoreError::BlockHeaderNotFound(
143                    ref_block_num,
144                ))
145            })?;
146
147        // 3. Fetch block headers for each lower ref (the ones needing authentication).
148        let fetched =
149            store.get_block_headers(&lower_refs).await.map_err(ClientError::StoreError)?;
150        let authenticated_blocks: Vec<BlockHeader> =
151            fetched.into_iter().map(|(header, _)| header).collect();
152        let fetched_nums: BTreeSet<BlockNumber> =
153            authenticated_blocks.iter().map(BlockHeader::block_num).collect();
154        if let Some(&missing) = lower_refs.difference(&fetched_nums).next() {
155            return Err(ClientError::StoreError(crate::store::StoreError::BlockHeaderNotFound(
156                missing,
157            )));
158        }
159
160        // 4. Build PartialMmr + PartialBlockchain using the current blockchain peaks — this matches
161        //    the MMR convention used by `ClientDataStore::get_transaction_inputs`.
162        let current_peaks =
163            store.get_current_blockchain_peaks().await.map_err(ClientError::StoreError)?;
164        let partial_mmr =
165            build_partial_mmr_with_paths(&store, current_peaks, &authenticated_blocks).await?;
166        let partial_blockchain = PartialBlockchain::new(partial_mmr, authenticated_blocks)?;
167
168        // 5. Split pushed_txs into the three views required by the remaining steps and build the
169        //    ProposedBatch.
170        let len = self.pushed_txs.len();
171        let mut proven_txs: Vec<Arc<ProvenTransaction>> = Vec::with_capacity(len);
172        let mut transaction_inputs: Vec<TransactionInputs> = Vec::with_capacity(len);
173        let mut tx_results: Vec<TransactionResult> = Vec::with_capacity(len);
174        for pushed in self.pushed_txs {
175            proven_txs.push(pushed.proven_tx);
176            transaction_inputs.push(pushed.transaction_inputs);
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. Submit via RPC.
196        let mut updates: Vec<TransactionStoreUpdate> = Vec::with_capacity(len);
197        let block_num = self
198            .client
199            .rpc_api
200            .submit_proven_batch(proven_batch, proposed_batch, transaction_inputs)
201            .await?;
202
203        // 8. Build per-tx TransactionStoreUpdates.
204        for tx_result in &tx_results {
205            let update =
206                self.client.get_transaction_store_update(tx_result, block_num).await.map_err(
207                    |source| BatchBuilderError::BatchSubmittedButUpdateBuildFailed {
208                        block_num,
209                        source,
210                    },
211                )?;
212            updates.push(update);
213        }
214
215        // 9. Apply atomically; if it fails, return BatchSubmittedButApplyFailed.
216        if let Err(source) = self.client.store.apply_transaction_batch(updates).await {
217            return Err(ClientError::from(BatchBuilderError::BatchSubmittedButApplyFailed {
218                block_num,
219                source,
220            }));
221        }
222
223        Ok(block_num)
224    }
225
226    /// Execute `req` against the batch's in-memory state for `account_id`, prove it using
227    /// the client's configured prover, and append the resulting proven transaction to the
228    /// batch. The first push for a given account lazily loads its state from the store.
229    ///
230    /// Consumes the builder and returns it on success. On failure the builder is dropped
231    /// along with every transaction accumulated so far; the caller cannot recover the
232    /// partial batch.
233    pub async fn push(
234        mut self,
235        account_id: AccountId,
236        req: TransactionRequest,
237    ) -> Result<Self, ClientError> {
238        // 1. Dedup input notes globally for the batch.
239        for note_id in req.input_note_ids() {
240            if self.consumed_input_notes.contains(&note_id) {
241                return Err(ClientError::from(BatchBuilderError::DuplicateInputNote(note_id)));
242            }
243        }
244
245        // 2. Execute against in-batch state, prove.
246        let tx_result =
247            execute_transaction_for_batch(self.client, &mut self.data_store, account_id, req)
248                .await?;
249        let tx_inputs = tx_result.executed_transaction().tx_inputs().clone();
250        let proven_tx = self.client.prove_transaction(&tx_result).await?;
251
252        // 3. Record consumed input notes, append PushedTx.
253        for note in tx_result.consumed_notes().iter() {
254            self.consumed_input_notes.insert(note.id());
255        }
256        self.pushed_txs.push(PushedTx {
257            proven_tx: Arc::new(proven_tx),
258            transaction_inputs: tx_inputs,
259            tx_result,
260        });
261        Ok(self)
262    }
263}
264
265/// Executes a single transaction, that is part of the batch to be sent to the node.
266/// Transaction is ran as the provided `Account`
267async fn execute_transaction_for_batch<AUTH>(
268    client: &Client<AUTH>,
269    data_store: &mut InMemoryBatchDataStore,
270    account_id: AccountId,
271    transaction_request: TransactionRequest,
272) -> Result<TransactionResult, ClientError>
273where
274    AUTH: TransactionAuthenticator + Sync + 'static,
275{
276    let mut account = if let Some(account) = data_store.get_account(account_id) {
277        account.clone()
278    } else {
279        let record = client
280            .store
281            .get_account(account_id)
282            .await?
283            .ok_or(ClientError::AccountDataNotFound(account_id))?;
284        if record.is_locked() {
285            return Err(ClientError::AccountLocked(account_id));
286        }
287        let account: Account = record.try_into()?;
288        account
289    };
290
291    let account_id = account.id();
292    let prep = client.prepare_transaction(&account, transaction_request).await?;
293
294    data_store.register_note_scripts(prep.output_note_scripts());
295    for fpi_account in &prep.foreign_account_inputs {
296        data_store.mast_store().load_account_code(fpi_account.code());
297    }
298    data_store.register_foreign_account_inputs(prep.foreign_account_inputs);
299
300    data_store.mast_store().load_account_code(account.code());
301
302    let mut notes = prep.notes;
303    if prep.ignore_invalid_notes {
304        notes = client
305            .get_valid_input_notes(&account, notes, prep.tx_args.clone(), &prep.output_recipients)
306            .await?;
307    }
308
309    let executed_transaction = client
310        .build_executor(data_store)?
311        .execute_transaction(account_id, prep.block_num, notes, prep.tx_args)
312        .await?;
313
314    // Cache new account state in memory data store.
315    let patch = executed_transaction.account_patch();
316    let account = if patch.is_full_state() {
317        Account::try_from(patch).map_err(ClientError::AccountError)?
318    } else {
319        account.apply_patch(patch).map_err(ClientError::AccountError)?;
320        account
321    };
322    data_store.cache_account(account_id, account);
323
324    validate_executed_transaction(&executed_transaction, &prep.output_recipients)?;
325    TransactionResult::new(executed_transaction, prep.future_notes)
326}