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 = build_partial_mmr_with_paths(
326 &store,
327 &self.client.rpc_api,
328 current_peaks,
329 &authenticated_blocks,
330 )
331 .await?;
332 let partial_blockchain = PartialBlockchain::new(partial_mmr, authenticated_blocks)?;
333
334 // 5. Split pushed_txs into the two views required by the remaining steps and build the
335 // ProposedBatch.
336 let len = self.pushed_txs.len();
337 let mut proven_txs: Vec<Arc<ProvenTransaction>> = Vec::with_capacity(len);
338 let mut tx_results: Vec<TransactionResult> = Vec::with_capacity(len);
339 for pushed in self.pushed_txs {
340 proven_txs.push(pushed.proven_tx);
341 tx_results.push(pushed.tx_result);
342 }
343
344 // TODO: field is left unused as of now because all txs in batch are already proven. This
345 // will be populated once a feature like remote proving in batches is implemented.
346 let unauthenticated_note_proofs = BTreeMap::new();
347 let proposed_batch = ProposedBatch::new(
348 proven_txs,
349 ref_block_header,
350 partial_blockchain,
351 unauthenticated_note_proofs,
352 MIN_PROOF_SECURITY_LEVEL,
353 )?;
354
355 // 6. Execute the batch kernel, then prove synchronously.
356 let executed_batch = BatchExecutor::new().execute(proposed_batch.clone())?;
357 let proven_batch =
358 LocalBatchProver::new(miden_tx::Prover::default()).prove(executed_batch)?;
359
360 // 7. Submit via RPC and record what the node took. The proven batch is kept so an
361 // unconfirmed submission can be retried without executing or proving again.
362 let submission = ProvenBatchSubmission {
363 proven_batch,
364 proposed_batch: Box::new(proposed_batch),
365 tx_results,
366 };
367 let block_num = self.client.send_and_apply_proven_batch(&submission).await?;
368
369 Ok(block_num)
370 }
371
372 /// Execute `req` against the batch's in-memory state for `account_id`, prove it using the
373 /// client's configured prover, and append the resulting proven transaction to the batch. The
374 /// first push for a given account lazily loads its state from the store.
375 ///
376 /// The batch is only advanced once the transaction has both executed and been proven, so on
377 /// failure the builder still holds exactly the transactions it held before the call and remains
378 /// usable. Returns `&mut Self` so pushes can be chained.
379 pub async fn push(
380 &mut self,
381 account_id: AccountId,
382 req: TransactionRequest,
383 ) -> Result<&mut Self, ClientError> {
384 // 1. Dedup input notes globally for the batch.
385 for note_id in req.input_note_ids() {
386 if self.consumed_input_notes.contains(¬e_id) {
387 return Err(ClientError::from(BatchBuilderError::DuplicateInputNote(note_id)));
388 }
389 }
390
391 // 2. Execute against in-batch state, then prove. Both run before any batch state is
392 // advanced, so a failure in either leaves the builder untouched. Execution holds a large
393 // future, boxed here so callers don't have to.
394 let tx_result =
395 Box::pin(execute_transaction_for_batch(self.client, &self.data_store, account_id, req))
396 .await?;
397
398 let proven_tx = self.client.prove_transaction(&tx_result).await?;
399
400 // 3. The transaction is final: fold it into the in-batch account state, record its consumed
401 // notes, and append it to the batch.
402 self.data_store
403 .apply_executed_transaction(tx_result.executed_transaction())
404 .await?;
405 for note in tx_result.consumed_notes().iter() {
406 self.consumed_input_notes.insert(note.id());
407 }
408 self.pushed_txs.push(PushedTx {
409 proven_tx: Arc::new(proven_tx),
410 tx_result,
411 });
412 Ok(self)
413 }
414}
415
416/// Executes a single transaction that is part of the batch to be sent to the node. The transaction
417/// runs against the current in-batch partial account state.
418async fn execute_transaction_for_batch<AUTH>(
419 client: &Client<AUTH>,
420 data_store: &InMemoryBatchDataStore,
421 account_id: AccountId,
422 transaction_request: TransactionRequest,
423) -> Result<TransactionResult, ClientError>
424where
425 AUTH: TransactionAuthenticator + Sync + 'static,
426{
427 let account_reader = client.account_reader(account_id);
428 if account_reader.status().await?.is_locked() {
429 return Err(ClientError::AccountLocked(account_id));
430 }
431
432 let account = match data_store.cached_account(account_id) {
433 Some(account) => account,
434 None => account_reader.partial_account().await?,
435 };
436
437 let prep = client.prepare_transaction_for_batch(&account, transaction_request).await?;
438
439 data_store.register_note_scripts(prep.output_note_scripts());
440 data_store.register_block_numbers(prep.block_numbers.iter().copied());
441 for fpi_account in &prep.foreign_account_inputs {
442 data_store.mast_store().load_account_code(fpi_account.code());
443 }
444 data_store.register_foreign_account_inputs(prep.foreign_account_inputs);
445
446 data_store.mast_store().load_account_code(account.code());
447
448 let mut notes = prep.notes;
449 if prep.ignore_invalid_notes {
450 notes = client
451 .get_valid_input_notes(
452 data_store,
453 account_id,
454 prep.block_num,
455 notes,
456 prep.tx_args.clone(),
457 )
458 .await?;
459 }
460
461 let executed_transaction = client
462 .build_executor(data_store)?
463 .execute_transaction(account_id, prep.block_num, notes, prep.tx_args)
464 .await?;
465
466 validate_executed_transaction(&executed_transaction, &prep.output_recipients)?;
467 TransactionResult::new(executed_transaction, prep.future_notes)
468}
469
470/// Promotes a batch submission failure whose outcome is unknown, attaching everything a retry
471/// needs. Any other failure is a rejection the node issued deliberately and passes through
472/// unchanged.
473fn promote_indeterminate_submission(
474 err: RpcError,
475 submission: &ProvenBatchSubmission,
476) -> ClientError {
477 if !err.is_indeterminate_submission() {
478 return ClientError::RpcError(err);
479 }
480
481 BatchBuilderError::BatchSubmissionOutcomeUnknown {
482 submission: Box::new(submission.clone()),
483 source: err,
484 }
485 .into()
486}