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_prover::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        )?;
189
190        // 6. Prove synchronously.
191        let proven_batch =
192            LocalBatchProver::new(MIN_PROOF_SECURITY_LEVEL).prove(proposed_batch.clone())?;
193
194        // 7. Submit via RPC.
195        let mut updates: Vec<TransactionStoreUpdate> = Vec::with_capacity(len);
196        let block_num = self
197            .client
198            .rpc_api
199            .submit_proven_batch(proven_batch, proposed_batch, transaction_inputs)
200            .await?;
201
202        // 8. Build per-tx TransactionStoreUpdates.
203        for tx_result in &tx_results {
204            let update =
205                self.client.get_transaction_store_update(tx_result, block_num).await.map_err(
206                    |source| BatchBuilderError::BatchSubmittedButUpdateBuildFailed {
207                        block_num,
208                        source,
209                    },
210                )?;
211            updates.push(update);
212        }
213
214        // 9. Apply atomically; if it fails, return BatchSubmittedButApplyFailed.
215        if let Err(source) = self.client.store.apply_transaction_batch(updates).await {
216            return Err(ClientError::from(BatchBuilderError::BatchSubmittedButApplyFailed {
217                block_num,
218                source,
219            }));
220        }
221
222        Ok(block_num)
223    }
224
225    /// Execute `req` against the batch's in-memory state for `account_id`, prove it using
226    /// the client's configured prover, and append the resulting proven transaction to the
227    /// batch. The first push for a given account lazily loads its state from the store.
228    ///
229    /// Consumes the builder and returns it on success. On failure the builder is dropped
230    /// along with every transaction accumulated so far; the caller cannot recover the
231    /// partial batch.
232    pub async fn push(
233        mut self,
234        account_id: AccountId,
235        req: TransactionRequest,
236    ) -> Result<Self, ClientError> {
237        // 1. Dedup input notes globally for the batch.
238        for note_id in req.input_note_ids() {
239            if self.consumed_input_notes.contains(&note_id) {
240                return Err(ClientError::from(BatchBuilderError::DuplicateInputNote(note_id)));
241            }
242        }
243
244        // 2. Execute against in-batch state, prove.
245        let tx_result =
246            execute_transaction_for_batch(self.client, &mut self.data_store, account_id, req)
247                .await?;
248        let tx_inputs = tx_result.executed_transaction().tx_inputs().clone();
249        let proven_tx = self.client.prove_transaction(&tx_result).await?;
250
251        // 3. Record consumed input notes, append PushedTx.
252        for note in tx_result.consumed_notes().iter() {
253            self.consumed_input_notes.insert(note.id());
254        }
255        self.pushed_txs.push(PushedTx {
256            proven_tx: Arc::new(proven_tx),
257            transaction_inputs: tx_inputs,
258            tx_result,
259        });
260        Ok(self)
261    }
262}
263
264/// Executes a single transaction, that is part of the batch to be sent to the node.
265/// Transaction is ran as the provided `Account`
266async fn execute_transaction_for_batch<AUTH>(
267    client: &Client<AUTH>,
268    data_store: &mut InMemoryBatchDataStore,
269    account_id: AccountId,
270    transaction_request: TransactionRequest,
271) -> Result<TransactionResult, ClientError>
272where
273    AUTH: TransactionAuthenticator + Sync + 'static,
274{
275    let mut account = if let Some(account) = data_store.get_account(account_id) {
276        account.clone()
277    } else {
278        let record = client
279            .store
280            .get_account(account_id)
281            .await?
282            .ok_or(ClientError::AccountDataNotFound(account_id))?;
283        if record.is_locked() {
284            return Err(ClientError::AccountLocked(account_id));
285        }
286        let account: Account = record.try_into()?;
287        account
288    };
289
290    let account_id = account.id();
291    let prep = client.prepare_transaction(&account, transaction_request).await?;
292
293    data_store.register_note_scripts(prep.output_note_scripts());
294    for fpi_account in &prep.foreign_account_inputs {
295        data_store.mast_store().load_account_code(fpi_account.code());
296    }
297    data_store.register_foreign_account_inputs(prep.foreign_account_inputs);
298
299    data_store.mast_store().load_account_code(account.code());
300
301    let mut notes = prep.notes;
302    if prep.ignore_invalid_notes {
303        notes = client
304            .get_valid_input_notes(&account, notes, prep.tx_args.clone(), &prep.output_recipients)
305            .await?;
306    }
307
308    let executed_transaction = client
309        .build_executor(data_store)?
310        .execute_transaction(account_id, prep.block_num, notes, prep.tx_args)
311        .await?;
312
313    // Cache new account state in memory data store
314    account
315        .apply_delta(executed_transaction.account_delta())
316        .map_err(ClientError::AccountError)?;
317    data_store.cache_account(account_id, account);
318
319    validate_executed_transaction(&executed_transaction, &prep.output_recipients)?;
320    TransactionResult::new(executed_transaction, prep.future_notes)
321}