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(¬e_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.prepare_transaction_for_batch(&account, transaction_request).await?;
310
311 data_store.register_note_scripts(prep.output_note_scripts());
312 for fpi_account in &prep.foreign_account_inputs {
313 data_store.mast_store().load_account_code(fpi_account.code());
314 }
315 data_store.register_foreign_account_inputs(prep.foreign_account_inputs);
316
317 data_store.mast_store().load_account_code(account.code());
318
319 let mut notes = prep.notes;
320 if prep.ignore_invalid_notes {
321 notes = client
322 .get_valid_input_notes(
323 data_store,
324 account_id,
325 prep.block_num,
326 notes,
327 prep.tx_args.clone(),
328 )
329 .await?;
330 }
331
332 let executed_transaction = client
333 .build_executor(data_store)?
334 .execute_transaction(account_id, prep.block_num, notes, prep.tx_args)
335 .await?;
336
337 validate_executed_transaction(&executed_transaction, &prep.output_recipients)?;
338 TransactionResult::new(executed_transaction, prep.future_notes)
339}