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