Skip to main content

miden_client/transaction/
mod.rs

1//! Provides APIs for creating, executing, proving, and submitting transactions to the Miden
2//! network.
3//!
4//! ## Overview
5//!
6//! This module enables clients to:
7//!
8//! - Build transaction requests using the [`TransactionRequestBuilder`].
9//!   - [`TransactionRequestBuilder`] contains simple builders for standard transaction types, such
10//!     as `p2id` (pay-to-id)
11//! - Execute transactions via the local transaction executor and generate a [`TransactionResult`]
12//!   that includes execution details and relevant notes for state tracking.
13//! - Prove transactions (locally or remotely) using a [`TransactionProver`] and submit the proven
14//!   transactions to the network.
15//! - Track and update the state of transactions, including their status (e.g., `Pending`,
16//!   `Committed`, or `Discarded`).
17//!
18//! ## Example
19//!
20//! The following example demonstrates how to create and submit a transaction:
21//!
22//! ```rust
23//! use miden_client::Client;
24//! use miden_client::auth::TransactionAuthenticator;
25//! use miden_client::crypto::FeltRng;
26//! use miden_client::transaction::{PaymentNoteDescription, TransactionRequestBuilder};
27//! use miden_protocol::account::AccountId;
28//! use miden_protocol::asset::FungibleAsset;
29//! use miden_protocol::note::NoteType;
30//! # use std::error::Error;
31//!
32//! /// Executes, proves and submits a P2ID transaction.
33//! ///
34//! /// This transaction is executed by `sender_id`, and creates an output note
35//! /// containing 100 tokens of `faucet_id`'s fungible asset.
36//! async fn create_and_submit_transaction<
37//!     R: rand::Rng,
38//!     AUTH: TransactionAuthenticator + Sync + 'static,
39//! >(
40//!     client: &mut Client<AUTH>,
41//!     sender_id: AccountId,
42//!     target_id: AccountId,
43//!     faucet_id: AccountId,
44//! ) -> Result<(), Box<dyn Error>> {
45//!     // Create an asset representing the amount to be transferred.
46//!     let asset = FungibleAsset::new(faucet_id, 100)?;
47//!
48//!     // Build a transaction request for a pay-to-id transaction.
49//!     let tx_request = TransactionRequestBuilder::new().build_pay_to_id(
50//!         PaymentNoteDescription::new(vec![asset.into()], sender_id, target_id),
51//!         NoteType::Private,
52//!         client.rng(),
53//!     )?;
54//!
55//!     // Execute, prove, and submit the transaction in a single call.
56//!     let _tx_id = client.submit_new_transaction(sender_id, tx_request).await?;
57//!
58//!     Ok(())
59//! }
60//! ```
61//!
62//! For more detailed information about each function and error type, refer to the specific API
63//! documentation.
64
65use alloc::boxed::Box;
66use alloc::collections::{BTreeMap, BTreeSet};
67use alloc::string::String;
68use alloc::sync::Arc;
69use alloc::vec::Vec;
70
71use miden_protocol::account::{AccountCode, AccountCodeInterface, AccountId, PartialAccount};
72use miden_protocol::asset::{Asset, NonFungibleAsset};
73use miden_protocol::block::{BlockHeader, BlockNumber, FeeParameters};
74use miden_protocol::errors::AssetError;
75use miden_protocol::note::{
76    Note,
77    NoteAttachments,
78    NoteDetails,
79    NoteId,
80    NoteRecipient,
81    NoteScript,
82    NoteTag,
83};
84use miden_protocol::transaction::PartialBlockchain;
85use miden_protocol::vm::MIN_STACK_DEPTH;
86use miden_protocol::{Felt, Word};
87use miden_standards::account::auth::FeeConversionInfo;
88use miden_standards::account::faucets::FungibleFaucet;
89use miden_standards::account::interface::AccountComponentInterfaceExt;
90use miden_standards::note::TxFeeNote;
91use miden_tx::{DataStore, NoteConsumptionChecker, TransactionExecutor};
92use tracing::info;
93
94use super::Client;
95use crate::ClientError;
96use crate::note::{NoteScreenerError, NoteUpdateTracker, StandardNote};
97use crate::rpc::domain::account::{
98    AccountStorageRequirements,
99    GetAccountRequest,
100    StorageMapFetch,
101    VaultFetch,
102};
103use crate::rpc::encryption::{TransactionEncryptionKey, seal_transaction_inputs};
104use crate::rpc::{AccountStateAt, NodeRpcClient, RpcError};
105use crate::store::data_store::{ClientDataStore, build_partial_mmr_with_paths};
106use crate::store::input_note_states::ExpectedNoteState;
107use crate::store::{
108    AccountRecord,
109    InputNoteRecord,
110    InputNoteState,
111    NoteFilter,
112    NoteRecordError,
113    OutputNoteRecord,
114    Store,
115    StoreError,
116    TransactionFilter,
117};
118use crate::sync::NoteTagRecord;
119use crate::transaction::batch::InMemoryBatchDataStore;
120
121pub mod batch;
122pub use batch::{BatchBuilder, BatchBuilderError};
123
124mod chain_anchor;
125pub use chain_anchor::{ChainAnchor, ChainAnchorError};
126
127#[cfg(feature = "dap")]
128mod dap_executor;
129mod prover;
130pub use prover::TransactionProver;
131
132mod record;
133pub use record::{
134    DiscardCause,
135    TransactionDetails,
136    TransactionRecord,
137    TransactionStatus,
138    TransactionStatusVariant,
139};
140
141mod store_update;
142pub use store_update::TransactionStoreUpdate;
143
144mod request;
145pub use request::{
146    ForeignAccount,
147    NoteArgs,
148    PaymentNoteDescription,
149    PswapTransactionData,
150    SwapTransactionData,
151    TransactionRequest,
152    TransactionRequestBuilder,
153    TransactionRequestError,
154    TransactionScriptTemplate,
155    build_fpi_script,
156};
157
158mod observer;
159pub use observer::TransactionObserver;
160
161mod result;
162// RE-EXPORTS
163// ================================================================================================
164pub use miden_protocol::transaction::{
165    AccountInputs,
166    ExecutedTransaction,
167    InputNote,
168    InputNotes,
169    OutputNote,
170    OutputNotes,
171    ProvenTransaction,
172    PublicOutputNote,
173    RawOutputNote,
174    RawOutputNotes,
175    TransactionArgs,
176    TransactionId,
177    TransactionInputs,
178    TransactionKernel,
179    TransactionScript,
180    TransactionScriptRoot,
181    TransactionSummary,
182};
183pub use miden_protocol::vm::{AdviceInputs, AdviceMap};
184pub use miden_standards::account::interface::{AccountComponentInterface, AccountInterface};
185pub use miden_standards::tx_script::{
186    ExpirationTransactionScript,
187    SendNotesTransactionScriptError,
188};
189pub use miden_tx::auth::TransactionAuthenticator;
190pub use miden_tx::{
191    DataStoreError,
192    LocalTransactionProver,
193    ProvingOptions,
194    TransactionExecutorError,
195    TransactionProverError,
196};
197pub use result::TransactionResult;
198
199// CONSTANTS
200// ================================================================================================
201
202/// Salt the client commits native fee conversion info under when the request declares none.
203///
204/// See [`attach_native_fee_conversion_info`] for why this is a constant.
205pub(crate) const NATIVE_FEE_CONVERSION_SALT: Word = Word::empty();
206
207/// Transaction management methods
208impl<AUTH> Client<AUTH>
209where
210    AUTH: TransactionAuthenticator + Sync + 'static,
211{
212    // TRANSACTION DATA RETRIEVAL
213    // --------------------------------------------------------------------------------------------
214
215    /// Retrieves tracked transactions, filtered by [`TransactionFilter`].
216    pub async fn get_transactions(
217        &self,
218        filter: TransactionFilter,
219    ) -> Result<Vec<TransactionRecord>, ClientError> {
220        self.store.get_transactions(filter).await.map_err(Into::into)
221    }
222
223    // TRANSACTION BATCH
224    // --------------------------------------------------------------------------------------------
225
226    /// Open a new [`BatchBuilder`] for accumulating transactions across one or more local
227    /// accounts.
228    ///
229    /// See [`crate::transaction::batch`] for usage and constraints.
230    pub fn new_transaction_batch(&mut self) -> BatchBuilder<'_, AUTH> {
231        let inner_data_store = ClientDataStore::new(self.store.clone(), self.rpc_api.clone());
232        BatchBuilder {
233            client: self,
234            data_store: InMemoryBatchDataStore::new(inner_data_store),
235            pushed_txs: Vec::new(),
236            consumed_input_notes: BTreeSet::new(),
237        }
238    }
239
240    // TRANSACTION
241    // --------------------------------------------------------------------------------------------
242
243    /// Executes a transaction specified by the request against the specified account,
244    /// proves it, submits it to the network, and updates the local database.
245    ///
246    /// Uses the client's default prover (configured via
247    /// [`crate::builder::ClientBuilder::prover`]).
248    pub async fn submit_new_transaction(
249        &mut self,
250        account_id: AccountId,
251        transaction_request: TransactionRequest,
252    ) -> Result<TransactionId, ClientError> {
253        let prover = self.tx_prover.clone();
254        self.submit_new_transaction_with_prover(account_id, transaction_request, prover)
255            .await
256    }
257
258    /// Executes a transaction specified by the request against the specified account,
259    /// proves it with the provided prover, submits it to the network, and updates the local
260    /// database.
261    ///
262    /// This is useful for falling back to a different prover (e.g., local) when the default
263    /// prover (e.g., remote) fails with a [`ClientError::TransactionProvingError`].
264    pub async fn submit_new_transaction_with_prover(
265        &mut self,
266        account_id: AccountId,
267        transaction_request: TransactionRequest,
268        tx_prover: Arc<dyn TransactionProver>,
269    ) -> Result<TransactionId, ClientError> {
270        // Register any missing NTX scripts before the main transaction.
271        // The registration path contains its own full execute -> prove -> submit pipeline.
272        if !transaction_request.expected_ntx_scripts().is_empty() {
273            Box::pin(self.ensure_ntx_scripts_registered(
274                account_id,
275                transaction_request.expected_ntx_scripts(),
276                tx_prover.clone(),
277            ))
278            .await?;
279        }
280
281        let tx_result = self.execute_transaction(account_id, transaction_request).await?;
282        let tx_id = tx_result.executed_transaction().id();
283
284        let proven_transaction = self.prove_transaction_with(&tx_result, tx_prover).await?;
285        let submission_height =
286            self.submit_proven_transaction(proven_transaction, &tx_result).await?;
287
288        // The transaction has been accepted by the node; the local store update
289        // is a separate step that can fail independently. On failure, return a
290        // distinct error carrying the pending update so the caller can decide
291        // how to recover (re-apply later via `apply_transaction_update`,
292        // persist for the next session, etc.).
293        //
294        // The update is boxed so it does not inflate the enclosing future
295        // across await points (triggers clippy::large_futures).
296        let tx_update =
297            Box::new(self.get_transaction_store_update(&tx_result, submission_height).await?);
298
299        if let Err(apply_err) = self.apply_transaction_update((*tx_update).clone()).await {
300            info!(
301                "apply_transaction_update failed for submitted tx {tx_id}; returning \
302                 ApplyTransactionAfterSubmitFailed with the pending update attached: {apply_err}"
303            );
304            return Err(ClientError::ApplyTransactionAfterSubmitFailed {
305                pending_update: tx_update,
306                source: Box::new(apply_err),
307            });
308        }
309
310        // Fire transaction observers (mirrors `apply_transaction`). Per-observer failures are
311        // logged and never propagate — they're feature-specific side-channels, not part of the
312        // submit contract.
313        for observer in &self.transaction_observers {
314            crate::errors::log_observer_failure(
315                observer.name(),
316                "TransactionObserver::apply",
317                observer.apply(&tx_result).await,
318            );
319        }
320
321        Ok(tx_id)
322    }
323
324    /// Creates and executes a transaction specified by the request against the specified account,
325    /// but doesn't change the local database.
326    ///
327    /// # Errors
328    ///
329    /// - Returns [`ClientError::MissingOutputRecipients`] if the [`TransactionRequest`] output
330    ///   notes are not a subset of executor's output notes.
331    /// - Returns a [`ClientError::TransactionExecutorError`] if the execution fails.
332    /// - Returns a [`ClientError::TransactionRequestError`] if the request is invalid.
333    pub async fn execute_transaction(
334        &self,
335        account_id: AccountId,
336        transaction_request: TransactionRequest,
337    ) -> Result<TransactionResult, ClientError> {
338        self.execute_transaction_with_mode(
339            account_id,
340            transaction_request,
341            TransactionExecutionMode::Standard,
342            None,
343        )
344        .await
345    }
346
347    /// Creates and executes a transaction specified by the request against the specified account,
348    /// using the provided [`ChainAnchor`] as the reference block instead of the current sync
349    /// height. Like [`Self::execute_transaction`], it doesn't change the local database.
350    ///
351    /// Since protocol 0.16 the signed transaction summary binds the reference block commitment,
352    /// so signatures collected over a summary only authorize an execution whose reference block
353    /// is the one the summary was built at. This method makes such an execution reproducible on
354    /// any client, regardless of its sync height: the anchor supplies the reference block header
355    /// and a consistent [`PartialBlockchain`], typically captured by the transaction's original
356    /// proposer via [`Self::chain_anchor_for_request`] and shipped alongside the signed data.
357    ///
358    /// The anchor pins the reference block only. The mode each input note is consumed in also
359    /// enters the summary, so a request shared across clients should pin it through
360    /// [`TransactionRequestBuilder::explicit_input_notes`]. Otherwise each client classifies the
361    /// notes from its own store, and two clients can commit to different input notes.
362    ///
363    /// Callers holding an anchor from an untrusted source should first compare
364    /// [`ChainAnchor::block_commitment`] against an independently trusted value (e.g. the block
365    /// commitment bound into the signed transaction summary).
366    ///
367    /// Foreign accounts are fetched at the anchor's block unless declared as
368    /// [`ForeignAccount::Prefetched`], so a node that no longer serves account state at that block
369    /// only affects accounts that are not prefetched.
370    ///
371    /// # Errors
372    ///
373    /// In addition to the [`Self::execute_transaction`] errors:
374    /// - Returns [`ClientError::ChainAnchorError`] if an authenticated input note's creation block
375    ///   is not tracked by the anchor.
376    /// - Returns a [`ClientError::TransactionExecutorError`] if an input note was created after the
377    ///   anchored reference block.
378    /// - Returns [`ChainAnchorError::AnchoredTransactionExpired`] if the executed transaction's
379    ///   expiration block has already been reached, which the network would reject.
380    pub async fn execute_transaction_at(
381        &mut self,
382        account_id: AccountId,
383        transaction_request: TransactionRequest,
384        anchor: ChainAnchor,
385    ) -> Result<TransactionResult, ClientError> {
386        let result = self
387            .execute_transaction_with_mode(
388                account_id,
389                transaction_request,
390                TransactionExecutionMode::Standard,
391                Some(Box::new(anchor)),
392            )
393            .await?;
394
395        // The expiration delta counts from the anchored reference block, so a stale anchor can
396        // yield an already-expired transaction, which the network would only reject after the
397        // caller has paid for proving. The sync height never runs ahead of the real tip, so this
398        // fires only on transactions that are certainly too late.
399        let expiration = result.executed_transaction().expiration_block_num();
400        let sync_height = self.store.get_sync_height().await?;
401        if expiration <= sync_height {
402            return Err(
403                ChainAnchorError::AnchoredTransactionExpired { expiration, sync_height }.into()
404            );
405        }
406
407        Ok(result)
408    }
409
410    /// Captures a [`ChainAnchor`] at the client's current sync height, tracking the blocks in
411    /// `tracked_blocks` (in addition to the reference block itself, which needs no tracking) so
412    /// that transactions consuming authenticated notes created in those blocks can later execute
413    /// against the anchor.
414    async fn chain_anchor_at_tip(
415        &self,
416        tracked_blocks: BTreeSet<BlockNumber>,
417    ) -> Result<ChainAnchor, ClientError> {
418        let sync_height = self.store.get_sync_height().await?;
419
420        let (header, _had_notes) = self
421            .store
422            .get_block_header_by_num(sync_height)
423            .await?
424            .ok_or(StoreError::BlockHeaderNotFound(sync_height))?;
425
426        let mut tracked_blocks = tracked_blocks;
427        // The kernel extends the MMR with the reference block itself, so it needs no path.
428        tracked_blocks.remove(&sync_height);
429
430        let block_headers: Vec<BlockHeader> = self
431            .store
432            .get_block_headers(&tracked_blocks)
433            .await?
434            .into_iter()
435            .map(|(header, _has_notes)| header)
436            .collect();
437
438        // `Store::get_block_headers` may silently omit missing headers, so verify each requested
439        // block is present rather than comparing lengths.
440        let fetched_nums: BTreeSet<BlockNumber> =
441            block_headers.iter().map(BlockHeader::block_num).collect();
442        if let Some(&missing) = tracked_blocks.difference(&fetched_nums).next() {
443            return Err(StoreError::BlockHeaderNotFound(missing).into());
444        }
445
446        let peaks = self.store.get_current_blockchain_peaks().await?;
447        let partial_mmr = build_partial_mmr_with_paths(&self.store, peaks, &block_headers).await?;
448
449        let chain = PartialBlockchain::new(partial_mmr, block_headers)?;
450
451        Ok(ChainAnchor::new(header, chain)?)
452    }
453
454    /// Captures a [`ChainAnchor`] at the client's current sync height, tracking the creation
455    /// blocks of the request's authenticated input notes so that the request can later execute
456    /// against the anchor. This covers notes the store holds as authenticated and notes pinned
457    /// as authenticated through [`TransactionRequestBuilder::explicit_input_notes`].
458    ///
459    /// This is the capture entry point for flows that never see a successful execution result at
460    /// capture time — e.g. multisig proposal flows, where execution intentionally fails with
461    /// [`TransactionExecutorError::Unauthorized`] to surface the transaction summary for signing.
462    /// Capture the anchor first, execute the request with [`Self::execute_transaction_at`], and
463    /// ship the anchor alongside the summary; the same anchor then reproduces the summary during
464    /// later verification and execution.
465    ///
466    /// # Errors
467    ///
468    /// - Returns [`ClientError::StoreError`] if a header for the sync height or a tracked block is
469    ///   not present in the store.
470    /// - Returns [`ChainAnchorError::TooManyTrackedBlocks`] if the request's authenticated input
471    ///   notes were created across more blocks than a transaction can reference.
472    pub async fn chain_anchor_for_request(
473        &self,
474        transaction_request: &TransactionRequest,
475    ) -> Result<ChainAnchor, ClientError> {
476        let inferred_input_note_ids: Vec<NoteId> = transaction_request
477            .input_note_ids()
478            .filter(|note_id| !transaction_request.explicit_input_notes.contains_key(note_id))
479            .collect();
480
481        let mut tracked_blocks: BTreeSet<BlockNumber> = if inferred_input_note_ids.is_empty() {
482            BTreeSet::new()
483        } else {
484            self.store
485                .get_input_notes(NoteFilter::List(inferred_input_note_ids))
486                .await?
487                .iter()
488                .filter(|record| record.is_authenticated())
489                .filter_map(|record| record.inclusion_proof())
490                .map(|proof| proof.location().block_num())
491                .collect()
492        };
493        tracked_blocks.extend(
494            transaction_request
495                .explicit_input_notes
496                .values()
497                .filter_map(InputNote::proof)
498                .map(|proof| proof.location().block_num()),
499        );
500
501        self.chain_anchor_at_tip(tracked_blocks).await
502    }
503
504    /// Executes `transaction_request` (e.g. consuming a note) through the DAP program executor,
505    /// so a DAP client can attach and step through the whole transaction — kernel, note scripts,
506    /// and account code — instead of only a standalone transaction script.
507    ///
508    /// This is a debugging entry point: it runs the transaction interactively under the debug
509    /// adapter and does not prove, submit, or apply the result. The listen address (and optional
510    /// replay-snapshot path) are taken from the globally installed
511    /// [`DapConfig`](miden_debug::DapConfig).
512    ///
513    /// # Errors
514    ///
515    /// This applies the same request preparation and output-recipient validation as
516    /// [`Self::execute_transaction`], and returns the corresponding [`ClientError`] on failure.
517    #[cfg(feature = "dap")]
518    pub async fn execute_transaction_with_dap(
519        &self,
520        account_id: AccountId,
521        transaction_request: TransactionRequest,
522    ) -> Result<TransactionResult, ClientError> {
523        self.execute_transaction_with_mode(
524            account_id,
525            transaction_request,
526            TransactionExecutionMode::Dap,
527            None,
528        )
529        .await
530    }
531
532    /// Executes a prepared transaction with the selected program executor while keeping request
533    /// preparation, data-store population, note filtering, and result validation identical across
534    /// execution modes.
535    async fn execute_transaction_with_mode(
536        &self,
537        account_id: AccountId,
538        transaction_request: TransactionRequest,
539        execution_mode: TransactionExecutionMode,
540        anchor: Option<Box<ChainAnchor>>,
541    ) -> Result<TransactionResult, ClientError> {
542        let account: PartialAccount =
543            self.get_native_account_record(account_id).await?.try_into()?;
544
545        let prep = self
546            .prepare_transaction(&account, transaction_request, anchor.as_deref())
547            .await?;
548
549        let mut data_store = ClientDataStore::new(self.store.clone(), self.rpc_api.clone());
550        if let Some(anchor) = anchor {
551            data_store = data_store.with_chain_anchor(*anchor);
552        }
553        data_store.register_note_scripts(prep.output_note_scripts());
554        for fpi_account in &prep.foreign_account_inputs {
555            data_store.mast_store().load_account_code(fpi_account.code());
556        }
557        data_store.register_foreign_account_inputs(prep.foreign_account_inputs);
558
559        data_store.mast_store().load_account_code(account.code());
560
561        let mut notes = prep.notes;
562        if prep.ignore_invalid_notes {
563            notes = self
564                .get_valid_input_notes(
565                    &data_store,
566                    account.id(),
567                    prep.block_num,
568                    notes,
569                    prep.tx_args.clone(),
570                )
571                .await?;
572        }
573
574        let executed_transaction = match execution_mode {
575            TransactionExecutionMode::Standard => {
576                self.build_executor(&data_store)?
577                    .execute_transaction(account_id, prep.block_num, notes, prep.tx_args)
578                    .await?
579            },
580            #[cfg(feature = "dap")]
581            TransactionExecutionMode::Dap => {
582                self.build_dap_executor(&data_store)?
583                    .execute_transaction(account_id, prep.block_num, notes, prep.tx_args)
584                    .await?
585            },
586        };
587
588        validate_executed_transaction(&executed_transaction, &prep.output_recipients)?;
589        TransactionResult::new(executed_transaction, prep.future_notes)
590    }
591
592    /// Performs the data-store-independent setup shared by `execute_transaction` and
593    /// `execute_transaction_for_batch`: validates the request against the account's committed
594    /// store state, loads/filters input notes, builds the transaction script and args, retrieves
595    /// foreign-account inputs, and computes the reference block number.
596    ///
597    /// This method does not write to the store: any state produced by the transaction is
598    /// persisted only after the transaction executes successfully.
599    ///
600    /// In batch execution, request validation is skipped: the committed store state does not
601    /// reflect balances stacked by prior in-batch pushes, so validating against it would wrongly
602    /// reject transactions the executor accepts.
603    ///
604    /// When `anchor` is provided, the reference block is the anchor's block instead of the
605    /// current sync height, and the recency check is skipped — anchored execution deliberately
606    /// references a block older than the tip.
607    pub(crate) async fn prepare_transaction(
608        &self,
609        account: &PartialAccount,
610        transaction_request: TransactionRequest,
611        anchor: Option<&ChainAnchor>,
612    ) -> Result<PreparedTransaction, ClientError> {
613        self.validate_account_request(
614            &transaction_request,
615            account.id(),
616            &account.code_interface(),
617        )
618        .await?;
619
620        self.prepare_transaction_inner(account.code_interface(), transaction_request, anchor)
621            .await
622    }
623
624    pub(crate) async fn prepare_transaction_for_batch(
625        &self,
626        account: &PartialAccount,
627        transaction_request: TransactionRequest,
628    ) -> Result<PreparedTransaction, ClientError> {
629        self.prepare_transaction_inner(account.code_interface(), transaction_request, None)
630            .await
631    }
632
633    async fn prepare_transaction_inner(
634        &self,
635        account_code_interface: AccountCodeInterface,
636        mut transaction_request: TransactionRequest,
637        anchor: Option<&ChainAnchor>,
638    ) -> Result<PreparedTransaction, ClientError> {
639        if anchor.is_none() {
640            self.validate_recency().await?;
641        }
642
643        // Retrieve all input notes from the store.
644        let mut stored_note_records = self
645            .store
646            .get_input_notes(NoteFilter::List(transaction_request.input_note_ids().collect()))
647            .await?;
648
649        // Verify that none of the stored input notes are already consumed.
650        for note in &stored_note_records {
651            if note.is_consumed() {
652                return Err(ClientError::TransactionRequestError(
653                    TransactionRequestError::InputNoteAlreadyConsumed(note.details_commitment()),
654                ));
655            }
656        }
657
658        // Only keep authenticated input notes from the store.
659        stored_note_records.retain(InputNoteRecord::is_authenticated);
660
661        let notes = transaction_request.build_input_notes(stored_note_records)?;
662
663        // Each authenticated note's creation block must be tracked by the anchor; fail with a
664        // typed error so callers can recapture a wider anchor. Notes newer than the anchor are
665        // left for the executor to reject.
666        if let Some(anchor) = anchor {
667            for note in notes.iter() {
668                if let Some(location) = note.location() {
669                    let block_num = location.block_num();
670                    if block_num < anchor.block_num()
671                        && !anchor.partial_blockchain().contains_block(block_num)
672                    {
673                        return Err(ChainAnchorError::BlockNotTracked { block_num }.into());
674                    }
675                }
676            }
677        }
678
679        let output_recipients =
680            transaction_request.expected_output_recipients().cloned().collect::<Vec<_>>();
681
682        let future_notes: Vec<(NoteDetails, NoteTag)> =
683            transaction_request.expected_future_notes().cloned().collect();
684
685        let tx_script = transaction_request.build_transaction_script(&account_code_interface)?;
686
687        let foreign_accounts = transaction_request.foreign_accounts().clone();
688
689        // The reference block: the anchor's block when pinned, the sync height otherwise.
690        // Foreign account proofs are fetched at this block to stay consistent with it.
691        let block_num = match anchor {
692            Some(anchor) => anchor.block_num(),
693            None => self.store.get_sync_height().await?,
694        };
695
696        let foreign_account_inputs = self
697            .get_foreign_account_inputs(foreign_accounts.into_values(), block_num)
698            .await?;
699
700        let ignore_invalid_notes = transaction_request.ignore_invalid_input_notes();
701
702        let reference_header = match anchor {
703            Some(anchor) => anchor.header().clone(),
704            None => {
705                self.store
706                    .get_block_header_by_num(block_num)
707                    .await?
708                    .ok_or(StoreError::BlockHeaderNotFound(block_num))?
709                    .0
710            },
711        };
712
713        // A witness opens against the account tree of exactly one block. Rejecting a mismatch here
714        // names the account and the block; inside the executor it would only be a kernel failure.
715        for inputs in &foreign_account_inputs {
716            if inputs.compute_account_root().ok() != Some(reference_header.account_root()) {
717                return Err(TransactionRequestError::ForeignAccountNotAtReferenceBlock {
718                    account_id: inputs.id(),
719                    block_num,
720                }
721                .into());
722            }
723        }
724
725        attach_native_fee_conversion_info(
726            &mut transaction_request,
727            &account_code_interface,
728            &reference_header,
729        )?;
730
731        let tx_args = transaction_request.into_transaction_args(tx_script);
732
733        Ok(PreparedTransaction {
734            notes,
735            output_recipients,
736            future_notes,
737            tx_args,
738            foreign_account_inputs,
739            block_num,
740            ignore_invalid_notes,
741        })
742    }
743
744    /// Proves the specified transaction using the prover configured for this client.
745    pub async fn prove_transaction(
746        &self,
747        tx_result: &TransactionResult,
748    ) -> Result<ProvenTransaction, ClientError> {
749        self.prove_transaction_with(tx_result, self.tx_prover.clone()).await
750    }
751
752    /// Proves the specified transaction using the provided prover.
753    ///
754    /// # Errors
755    ///
756    /// - Returns a [`ClientError::TransactionProvingError`] if the prover fails to produce a proof.
757    /// - Returns a [`ClientError::MismatchedProvenTransaction`] if the prover returns a proof of a
758    ///   transaction other than the requested one.
759    pub async fn prove_transaction_with(
760        &self,
761        tx_result: &TransactionResult,
762        tx_prover: Arc<dyn TransactionProver>,
763    ) -> Result<ProvenTransaction, ClientError> {
764        info!("Proving transaction...");
765
766        let executed_transaction = tx_result.executed_transaction();
767        let proven_transaction = tx_prover.prove(executed_transaction.clone().into()).await?;
768
769        // A prover is trusted with the witness, but not with choosing which transaction gets
770        // submitted. Everything downstream (submission, the local store update, the returned
771        // id) is derived from `tx_result`, so a proof of anything else would be submitted
772        // while the local state recorded the transaction that never reached the network.
773        //
774        // The id commits to the initial and final account commitments and to the input and
775        // output note commitments; the account commitments in turn commit to the account id,
776        // so a matching id covers the account as well.
777        if proven_transaction.id() != executed_transaction.id() {
778            return Err(ClientError::MismatchedProvenTransaction {
779                requested: executed_transaction.id(),
780                returned: proven_transaction.id(),
781            });
782        }
783
784        info!("Transaction proven.");
785
786        Ok(proven_transaction)
787    }
788
789    /// Submits a previously proven transaction to the RPC endpoint and returns the node’s chain tip
790    /// upon mempool admission.
791    ///
792    /// # Errors
793    ///
794    /// Returns [`ClientError::SubmissionOutcomeUnknown`] when the submission came back without a
795    /// definite answer. It carries the proven transaction and the inputs it was submitted with, so
796    /// a retry does not have to execute or prove again. Every other failure is a rejection the
797    /// node issued deliberately.
798    pub async fn submit_proven_transaction(
799        &mut self,
800        proven_transaction: ProvenTransaction,
801        transaction_inputs: impl Into<TransactionInputs>,
802    ) -> Result<BlockNumber, ClientError> {
803        info!("Submitting transaction to the network...");
804        let tx_id = proven_transaction.id();
805        let key = self.transaction_encryption_key().await?;
806
807        // Both are kept so an indeterminate outcome can hand back everything a retry needs. The
808        // inputs cannot be recovered from the proven transaction, which only commits to them, and
809        // sealing draws fresh randomness so every attempt has to seal again.
810        let transaction_inputs = transaction_inputs.into();
811        let submitted = proven_transaction.clone();
812
813        let sealed_inputs =
814            seal_transaction_inputs(&mut self.rng, &key, tx_id, &transaction_inputs)?;
815
816        let result =
817            self.rpc_api.submit_proven_transaction(proven_transaction, sealed_inputs).await;
818        if let Err(err) = &result {
819            self.forget_stale_transaction_encryption_key(err).await;
820        }
821
822        let block_num = result
823            .map_err(|err| promote_indeterminate_submission(err, submitted, transaction_inputs))?;
824        info!("Transaction submitted.");
825
826        Ok(block_num)
827    }
828
829    /// Returns the validator set's transaction encryption key, fetching and verifying it on first
830    /// use.
831    ///
832    /// The key is public data shared by the whole validator set, so it is cached in the store and
833    /// reused across submissions and restarts. A freshly fetched key is verified against the
834    /// validator set committed in the chain tip before it is cached or used: the endpoint is served
835    /// by the RPC operator, which is the party the encryption keeps out.
836    pub(crate) async fn transaction_encryption_key(
837        &self,
838    ) -> Result<TransactionEncryptionKey, ClientError> {
839        if let Some(key) = self.store.get_transaction_encryption_key().await? {
840            return Ok(key);
841        }
842
843        let attested = self.rpc_api.get_transaction_encryption_key().await?;
844
845        // The genesis commitment scopes the attestation to this chain, and the chain tip carries
846        // the validator set currently entitled to attest. Both come from the local store, so a
847        // response cannot supply its own trust anchor.
848        let genesis_commitment =
849            self.trusted_block_header(BlockNumber::GENESIS).await?.commitment();
850        let chain_tip = self.store.get_sync_height().await?;
851        let validator_keys = self.trusted_block_header(chain_tip).await?.validator_keys().clone();
852
853        let key = attested.verify(genesis_commitment, &validator_keys)?;
854        self.store.set_transaction_encryption_key(&key).await?;
855
856        Ok(key)
857    }
858
859    /// Installs the transaction encryption key that submission seals against, skipping the fetch
860    /// and its attestation check.
861    #[cfg(feature = "testing")]
862    pub async fn seed_transaction_encryption_key(
863        &self,
864        key: TransactionEncryptionKey,
865    ) -> Result<(), ClientError> {
866        Ok(self.store.set_transaction_encryption_key(&key).await?)
867    }
868
869    /// Evicts the cached encryption key when a submission was rejected for having been sealed
870    /// against a key the validator does not hold, so the next submission fetches a fresh one.
871    ///
872    /// An eviction failure is logged rather than returned: the caller is already reporting the
873    /// submission error, which the store error must not mask.
874    pub(crate) async fn forget_stale_transaction_encryption_key(&self, err: &RpcError) {
875        if err.is_stale_transaction_encryption_key()
876            && let Err(err) = self.store.remove_transaction_encryption_key().await
877        {
878            tracing::warn!("failed to evict the stale transaction encryption key: {err}");
879        }
880    }
881
882    /// Returns a locally stored block header, which the client has already authenticated during
883    /// sync.
884    ///
885    /// # Errors
886    /// Returns an error if the header is not stored locally, which means the client has not synced
887    /// far enough to have a trust anchor.
888    async fn trusted_block_header(
889        &self,
890        block_num: BlockNumber,
891    ) -> Result<BlockHeader, ClientError> {
892        self.store.get_block_header_by_num(block_num).await?.map(|(header, _)| header).ok_or_else(
893            || {
894                ClientError::ChainValidationError(alloc::format!(
895                    "block header {block_num} is not tracked locally; sync the client before it can verify data against the chain"
896                ))
897            },
898        )
899    }
900
901    /// Builds a [`TransactionStoreUpdate`] for the provided transaction result at the specified
902    /// submission height.
903    pub async fn get_transaction_store_update(
904        &self,
905        tx_result: &TransactionResult,
906        submission_height: BlockNumber,
907    ) -> Result<TransactionStoreUpdate, TransactionStoreUpdateError> {
908        let note_updates = self.get_note_updates(submission_height, tx_result).await?;
909
910        // Only expected input notes need tags; output notes are committed (with proofs)
911        // via account-matched transaction sync.
912        let new_tags: Vec<NoteTagRecord> = note_updates
913            .updated_input_notes()
914            .filter_map(|note| {
915                let note = note.inner();
916
917                if let InputNoteState::Expected(ExpectedNoteState { tag: Some(tag), .. }) =
918                    note.state()
919                {
920                    Some(NoteTagRecord::with_note_source(*tag, note.details_commitment()))
921                } else {
922                    None
923                }
924            })
925            .collect();
926
927        Ok(TransactionStoreUpdate::new(
928            tx_result.executed_transaction().clone(),
929            submission_height,
930            note_updates,
931            tx_result.future_notes().to_vec(),
932            new_tags,
933        ))
934    }
935
936    /// Persists the effects of a submitted transaction into the local store,
937    /// updating account data, note metadata, and future note tracking.
938    pub async fn apply_transaction(
939        &self,
940        tx_result: &TransactionResult,
941        submission_height: BlockNumber,
942    ) -> Result<(), ClientError> {
943        let tx_update = self.get_transaction_store_update(tx_result, submission_height).await?;
944
945        self.apply_transaction_update(tx_update).await?;
946
947        // Fire transaction observers. Per-observer failures are logged.
948        for observer in &self.transaction_observers {
949            if let Err(err) = observer.apply(tx_result).await {
950                tracing::warn!(
951                    observer = observer.name(),
952                    error = ?err,
953                    "TransactionObserver::apply failed; continuing with remaining observers",
954                );
955            }
956        }
957
958        Ok(())
959    }
960
961    pub async fn apply_transaction_update(
962        &self,
963        tx_update: TransactionStoreUpdate,
964    ) -> Result<(), ClientError> {
965        // Transaction was proven and submitted to the node correctly, persist note details and
966        // update account
967        info!("Applying transaction to the local store...");
968
969        let executed_transaction = tx_update.executed_transaction();
970        let account_id = executed_transaction.account_id();
971
972        if self.account_reader(account_id).status().await?.is_locked() {
973            return Err(ClientError::AccountLocked(account_id));
974        }
975
976        self.store.apply_transaction(tx_update).await?;
977        info!("Transaction stored.");
978        Ok(())
979    }
980
981    /// Executes the provided transaction script against the specified account, and returns the
982    /// resulting stack. Advice inputs and foreign accounts can be provided for the execution.
983    ///
984    /// The transaction will use the current sync height as the block reference.
985    pub async fn execute_program(
986        &self,
987        account_id: AccountId,
988        tx_script: TransactionScript,
989        advice_inputs: AdviceInputs,
990        foreign_accounts: BTreeMap<AccountId, ForeignAccount>,
991    ) -> Result<[Felt; MIN_STACK_DEPTH], ClientError> {
992        let (data_store, block_ref) =
993            self.prepare_program_execution(account_id, foreign_accounts).await?;
994
995        Ok(self
996            .build_executor(&data_store)?
997            .execute_tx_view_script(account_id, block_ref, tx_script, advice_inputs)
998            .await?)
999    }
1000
1001    /// Executes the provided transaction script with a DAP debug adapter listening for
1002    /// connections, allowing interactive debugging via any DAP-compatible client.
1003    #[cfg(feature = "dap")]
1004    pub async fn execute_program_with_dap(
1005        &self,
1006        account_id: AccountId,
1007        tx_script: TransactionScript,
1008        advice_inputs: AdviceInputs,
1009        foreign_accounts: BTreeMap<AccountId, ForeignAccount>,
1010    ) -> Result<[Felt; MIN_STACK_DEPTH], ClientError> {
1011        let (data_store, block_ref) =
1012            self.prepare_program_execution(account_id, foreign_accounts).await?;
1013
1014        Ok(self
1015            .build_dap_executor(&data_store)?
1016            .execute_tx_view_script(account_id, block_ref, tx_script, advice_inputs)
1017            .await?)
1018    }
1019
1020    // HELPERS
1021    // --------------------------------------------------------------------------------------------
1022
1023    /// Validates that the specified transaction request can be executed by the specified account.
1024    ///
1025    /// This does't guarantee that the transaction will succeed, but it's useful to avoid submitting
1026    /// transactions that are guaranteed to fail. Some of the validations include:
1027    /// - That the account has enough balance to cover the outgoing assets.
1028    /// - That the client is not too far behind the chain tip.
1029    pub async fn validate_request(
1030        &self,
1031        account_id: AccountId,
1032        transaction_request: &TransactionRequest,
1033    ) -> Result<(), ClientError> {
1034        self.validate_recency().await?;
1035        validate_output_note_senders(transaction_request, account_id)?;
1036        let account: PartialAccount = self
1037            .store
1038            .get_minimal_partial_account(account_id)
1039            .await?
1040            .ok_or(ClientError::AccountDataNotFound(account_id))?
1041            .try_into()?;
1042        self.validate_account_request(transaction_request, account_id, &account.code_interface())
1043            .await
1044    }
1045
1046    /// Validates the request against the account's committed store state: faucet accounts are
1047    /// accepted as-is, other accounts get their vault asset list checked against the request's
1048    /// outgoing assets. Only the asset list is loaded from the store; the account itself is not
1049    /// reconstructed.
1050    async fn validate_account_request(
1051        &self,
1052        transaction_request: &TransactionRequest,
1053        account_id: AccountId,
1054        account_code_interface: &AccountCodeInterface,
1055    ) -> Result<(), ClientError> {
1056        validate_fee_conversion_info_support(transaction_request, account_code_interface)?;
1057
1058        if account_code_interface.contains([FungibleFaucet::mint_and_send_root()]) {
1059            // TODO(#1266): Add faucet validations.
1060            Ok(())
1061        } else {
1062            let assets = self.account_reader(account_id).assets().await?;
1063            validate_basic_account_request(transaction_request, &assets)
1064        }
1065    }
1066
1067    async fn validate_recency(&self) -> Result<(), ClientError> {
1068        if let Some(max_block_number_delta) = self.max_block_number_delta {
1069            let current_chain_tip =
1070                self.rpc_api.get_block_header_by_number(None, false).await?.0.block_num();
1071
1072            if current_chain_tip > self.store.get_sync_height().await? + max_block_number_delta {
1073                return Err(ClientError::RecencyConditionError(
1074                    "The client is too far behind the chain tip to execute the transaction",
1075                ));
1076            }
1077        }
1078        Ok(())
1079    }
1080
1081    /// Checks whether the node's `note_scripts` registry already has each of the expected NTX
1082    /// scripts. For any script that is missing, creates and submits a registration transaction
1083    /// that produces a public note carrying that script.
1084    ///
1085    /// `account_id` is the account that will execute the registration transaction.
1086    ///
1087    /// Standard note scripts are skipped — the NTX builder resolves those directly, so they
1088    /// never need registering. A missing non-standard script is registered, not an error.
1089    ///
1090    /// This method is called automatically by [`Self::submit_new_transaction_with_prover`] when the
1091    /// [`TransactionRequest`] contains expected NTX scripts. It can also be called directly if
1092    /// you want to register scripts ahead of time.
1093    pub async fn ensure_ntx_scripts_registered(
1094        &mut self,
1095        account_id: AccountId,
1096        scripts: &[NoteScript],
1097        tx_prover: Arc<dyn TransactionProver>,
1098    ) -> Result<(), ClientError> {
1099        let mut missing_scripts = Vec::new();
1100
1101        for script in scripts {
1102            // Standard scripts are resolved by the NTX builder directly; no registration needed.
1103            if StandardNote::from_script(script).is_some() {
1104                continue;
1105            }
1106
1107            let script_root = script.root();
1108
1109            // Scripts the node doesn't have are queued for registration; only RPC errors abort.
1110            match self.rpc_api.get_note_script_by_root(script_root.into()).await {
1111                Ok(Some(_)) => {},
1112                Ok(None) => missing_scripts.push(script.clone()),
1113                Err(source) => {
1114                    return Err(ClientError::NtxScriptRegistrationFailed {
1115                        script_root: script_root.into(),
1116                        source,
1117                    });
1118                },
1119            }
1120        }
1121
1122        if missing_scripts.is_empty() {
1123            return Ok(());
1124        }
1125
1126        let registration_request = TransactionRequestBuilder::new().build_register_note_scripts(
1127            account_id,
1128            missing_scripts,
1129            self.rng(),
1130        )?;
1131
1132        let tx_result = self.execute_transaction(account_id, registration_request).await?;
1133        let proven = self.prove_transaction_with(&tx_result, tx_prover).await?;
1134        let submission_height = self.submit_proven_transaction(proven, &tx_result).await?;
1135        self.apply_transaction(&tx_result, submission_height).await?;
1136
1137        Ok(())
1138    }
1139
1140    /// Filters the provided input notes down to the subset that can be consumed by the account.
1141    ///
1142    /// The provided data store must already have the account's code loaded and the request's
1143    /// output note scripts registered, so output note creation can resolve them without them
1144    /// being present in the store.
1145    ///
1146    /// The trial runs against `data_store` at `block_ref`, which must match the reference block
1147    /// the actual execution will use.
1148    pub(crate) async fn get_valid_input_notes<STORE: DataStore + Sync>(
1149        &self,
1150        data_store: &STORE,
1151        account_id: AccountId,
1152        block_ref: BlockNumber,
1153        mut input_notes: InputNotes<InputNote>,
1154        tx_args: TransactionArgs,
1155    ) -> Result<InputNotes<InputNote>, ClientError> {
1156        loop {
1157            // The consumption checker rejects a zero-note call; the set can be empty because the
1158            // request carried no notes or because screening removed them all.
1159            if input_notes.is_empty() {
1160                break;
1161            }
1162
1163            let execution = NoteConsumptionChecker::new(&self.build_executor(data_store)?)
1164                .check_notes_consumability(
1165                    account_id,
1166                    block_ref,
1167                    input_notes.iter().map(|n| n.clone().into_note()).collect(),
1168                    tx_args.clone(),
1169                )
1170                .await?;
1171
1172            if execution.failed().is_empty() {
1173                break;
1174            }
1175
1176            let failed_note_ids: BTreeSet<NoteId> =
1177                execution.failed().iter().map(|n| n.note().id()).collect();
1178            let filtered_input_notes = InputNotes::new(
1179                input_notes
1180                    .into_iter()
1181                    .filter(|note| !failed_note_ids.contains(&note.id()))
1182                    .collect(),
1183            )
1184            .expect("Created from a valid input notes list");
1185
1186            input_notes = filtered_input_notes;
1187        }
1188
1189        Ok(input_notes)
1190    }
1191
1192    /// Returns foreign account inputs for the required foreign accounts specified by the
1193    /// transaction request, with proofs anchored at `block_num` — the transaction's reference
1194    /// block, so that the fetched state is consistent with the block the transaction executes
1195    /// against.
1196    ///
1197    /// For any [`ForeignAccount::Public`] in `foreign_accounts`, these pieces of data are retrieved
1198    /// from the network. For any [`ForeignAccount::Private`] account, inner data is used and only
1199    /// a proof of the account's existence on the network is fetched. A
1200    /// [`ForeignAccount::Prefetched`] account is returned as is.
1201    ///
1202    /// Each witness opens against the account tree of `block_num`, so the results are valid only
1203    /// for a transaction whose reference block is exactly `block_num`. Declared as
1204    /// [`ForeignAccount::Prefetched`], they are served from the request instead of being fetched.
1205    /// Under [`Self::execute_transaction_at`] the reference block is the anchor's block; otherwise
1206    /// it is the sync height at execution time, so do not sync between fetching and executing.
1207    /// Only the given accounts are fetched; this method does not discover the accounts a
1208    /// transaction loads, such as faucets whose asset callbacks it triggers.
1209    ///
1210    /// # Errors
1211    ///
1212    /// Returns an error if account data cannot be fetched or converted to transaction inputs.
1213    pub async fn get_foreign_account_inputs(
1214        &self,
1215        foreign_accounts: impl IntoIterator<Item = ForeignAccount>,
1216        block_num: BlockNumber,
1217    ) -> Result<Vec<AccountInputs>, ClientError> {
1218        let foreign_accounts = foreign_accounts.into_iter();
1219        let mut return_foreign_account_inputs = Vec::with_capacity(foreign_accounts.size_hint().0);
1220
1221        for foreign_account in foreign_accounts {
1222            let foreign_account_inputs = match foreign_account {
1223                ForeignAccount::Public(account_id, storage_requirements) => {
1224                    fetch_public_account_inputs(
1225                        &self.store,
1226                        &self.rpc_api,
1227                        account_id,
1228                        storage_requirements,
1229                        AccountStateAt::Block(block_num),
1230                    )
1231                    .await?
1232                },
1233                ForeignAccount::Private(partial_account) => {
1234                    let account_id = partial_account.id();
1235                    let (_, account_proof) = self
1236                        .rpc_api
1237                        .get_account(
1238                            account_id,
1239                            GetAccountRequest::new().at(AccountStateAt::Block(block_num)),
1240                        )
1241                        .await?;
1242                    let (witness, _) = account_proof.into_parts();
1243                    AccountInputs::new(partial_account, witness)
1244                },
1245                ForeignAccount::Prefetched(inputs) => inputs,
1246            };
1247
1248            return_foreign_account_inputs.push(foreign_account_inputs);
1249        }
1250
1251        Ok(return_foreign_account_inputs)
1252    }
1253
1254    /// Prepares the data store and block reference for program execution.
1255    ///
1256    /// This is shared setup for both `execute_program` and `execute_program_with_dap`.
1257    async fn prepare_program_execution(
1258        &self,
1259        account_id: AccountId,
1260        foreign_accounts: BTreeMap<AccountId, ForeignAccount>,
1261    ) -> Result<(ClientDataStore, BlockNumber), ClientError> {
1262        let block_ref = self.get_sync_height().await?;
1263
1264        let foreign_account_inputs = self
1265            .get_foreign_account_inputs(foreign_accounts.into_values(), block_ref)
1266            .await?;
1267
1268        let account_code = self
1269            .store
1270            .get_account_code(account_id)
1271            .await?
1272            .ok_or(ClientError::AccountDataNotFound(account_id))?;
1273
1274        let data_store = ClientDataStore::new(self.store.clone(), self.rpc_api.clone());
1275
1276        // Ensure code is loaded on MAST store
1277        data_store.mast_store().load_account_code(&account_code);
1278
1279        for fpi_account in &foreign_account_inputs {
1280            data_store.mast_store().load_account_code(fpi_account.code());
1281        }
1282
1283        data_store.register_foreign_account_inputs(foreign_account_inputs);
1284
1285        Ok((data_store, block_ref))
1286    }
1287
1288    /// Creates a transaction executor configured with the client's runtime options,
1289    /// authenticator, and source manager.
1290    pub(crate) fn build_executor<'store, 'auth, STORE: DataStore + Sync>(
1291        &'auth self,
1292        data_store: &'store STORE,
1293    ) -> Result<TransactionExecutor<'store, 'auth, STORE, AUTH>, TransactionExecutorError> {
1294        let mut executor = TransactionExecutor::new(data_store)
1295            .with_options(self.exec_options)?
1296            .with_source_manager(self.source_manager.clone());
1297        if let Some(authenticator) = self.authenticator.as_deref() {
1298            executor = executor.with_authenticator(authenticator);
1299        }
1300        Ok(executor)
1301    }
1302
1303    /// Loads a minimal partial [`AccountRecord`] for an account that must be usable as a
1304    /// transaction's native account. Errors out if the account is not tracked or if it is
1305    /// watched. The full account state is never loaded: the executor reads it lazily through the
1306    /// [`DataStore`].
1307    async fn get_native_account_record(
1308        &self,
1309        account_id: AccountId,
1310    ) -> Result<AccountRecord, ClientError> {
1311        let account_record = self
1312            .store
1313            .get_minimal_partial_account(account_id)
1314            .await?
1315            .ok_or(ClientError::AccountDataNotFound(account_id))?;
1316        if account_record.is_watched() {
1317            return Err(ClientError::AccountIsWatched(account_id));
1318        }
1319        Ok(account_record)
1320    }
1321
1322    /// Creates a transaction executor configured for DAP (Debug Adapter Protocol) debugging.
1323    #[cfg(feature = "dap")]
1324    pub(crate) fn build_dap_executor<'store, 'auth, STORE: DataStore + Sync>(
1325        &'auth self,
1326        data_store: &'store STORE,
1327    ) -> Result<
1328        TransactionExecutor<'store, 'auth, STORE, AUTH, dap_executor::DapProgramExecutor>,
1329        TransactionExecutorError,
1330    > {
1331        Ok(self
1332            .build_executor(data_store)?
1333            .with_program_executor::<dap_executor::DapProgramExecutor>())
1334    }
1335
1336    /// Returns [`NoteUpdateTracker`] containing the note updates generated by an executed
1337    /// transaction.
1338    async fn get_note_updates(
1339        &self,
1340        submission_height: BlockNumber,
1341        tx_result: &TransactionResult,
1342    ) -> Result<NoteUpdateTracker, TransactionStoreUpdateError> {
1343        let executed_tx = tx_result.executed_transaction();
1344        let current_timestamp = self.store.get_current_timestamp();
1345        let current_block_num = self.store.get_sync_height().await?;
1346
1347        // New output notes
1348        //
1349        // The kernel's fee note is excluded. It is a bearer note for whoever builds the batch, so
1350        // tracking it would return it from `get_output_notes(NoteFilter::All)` as a note the user
1351        // created, list it in `miden-client notes`, and -- because `STATE_EXPECTED_FULL` is inside
1352        // the `Unspent` filter -- feed its nullifier prefix into `sync_nullifiers` on every sync,
1353        // making the client ask the node about a note it does not own once per fee-paying
1354        // transaction. Nothing is lost by excluding it: the complete raw output list is already
1355        // kept verbatim on the transaction record (`TransactionDetails.output_notes`).
1356        //
1357        // Same discriminator, same reason as the input-note loop below.
1358        let new_output_notes = executed_tx
1359            .output_notes()
1360            .iter()
1361            .filter(|output_note| {
1362                output_note
1363                    .recipient()
1364                    .is_none_or(|recipient| recipient.script().root() != TxFeeNote::script_root())
1365            })
1366            .cloned()
1367            .filter_map(|output_note| {
1368                OutputNoteRecord::try_from_output_note(output_note, submission_height).ok()
1369            })
1370            .collect::<Vec<_>>();
1371
1372        // New relevant input notes
1373        let mut new_input_notes = vec![];
1374        let output_notes: Vec<Note> =
1375            notes_from_output(executed_tx.output_notes()).cloned().collect();
1376        let note_screener = self.note_screener().clone();
1377        let output_note_relevances = note_screener.get_batch_consumability(&output_notes).await?;
1378
1379        for note in output_notes {
1380            // The fee note is a bearer note meant for whoever builds the batch, so the screener
1381            // wrongly reports it as consumable here. Tracking it would also register its tag, and
1382            // all TX_FEE notes share one chain-wide tag, so every later sync would pull in every
1383            // fee note the chain has produced.
1384            if note.script().root() == TxFeeNote::script_root() {
1385                continue;
1386            }
1387
1388            if output_note_relevances.contains_key(&note.id()) {
1389                let metadata = *note.metadata();
1390                let tag = metadata.tag();
1391                let attachments = note.attachments().clone();
1392
1393                new_input_notes.push(InputNoteRecord::new(
1394                    note.into(),
1395                    attachments,
1396                    current_timestamp,
1397                    ExpectedNoteState {
1398                        metadata: Some(metadata),
1399                        after_block_num: submission_height,
1400                        tag: Some(tag),
1401                    }
1402                    .into(),
1403                ));
1404            }
1405        }
1406
1407        // Track future input notes described in the transaction result.
1408        new_input_notes.extend(tx_result.future_notes().iter().map(|(note_details, tag)| {
1409            InputNoteRecord::new(
1410                note_details.clone(),
1411                NoteAttachments::empty(),
1412                None,
1413                ExpectedNoteState {
1414                    metadata: None,
1415                    after_block_num: current_block_num,
1416                    tag: Some(*tag),
1417                }
1418                .into(),
1419            )
1420        }));
1421
1422        // Locally consumed notes. Notes already tracked by the store only need their state
1423        // advanced; the rest (the request's unauthenticated notes, which are not persisted
1424        // before the transaction succeeds) are tracked from this point on, so records for them
1425        // are built from the executed transaction's inputs.
1426        let consumed_note_ids =
1427            executed_tx.tx_inputs().input_notes().iter().map(InputNote::id).collect();
1428
1429        let consumed_notes =
1430            self.store.get_input_notes(NoteFilter::List(consumed_note_ids)).await?;
1431
1432        let tracked_note_ids =
1433            consumed_notes.iter().filter_map(InputNoteRecord::id).collect::<BTreeSet<_>>();
1434
1435        for input_note in executed_tx.tx_inputs().input_notes() {
1436            if !tracked_note_ids.contains(&input_note.id()) {
1437                let mut input_note_record = InputNoteRecord::from(input_note.clone());
1438                input_note_record.consumed_locally(
1439                    executed_tx.account_id(),
1440                    executed_tx.id(),
1441                    current_timestamp,
1442                )?;
1443                new_input_notes.push(input_note_record);
1444            }
1445        }
1446
1447        let mut updated_input_notes = vec![];
1448
1449        for mut input_note_record in consumed_notes {
1450            if input_note_record.consumed_locally(
1451                executed_tx.account_id(),
1452                executed_tx.id(),
1453                current_timestamp,
1454            )? {
1455                updated_input_notes.push(input_note_record);
1456            }
1457        }
1458
1459        Ok(NoteUpdateTracker::for_transaction_updates(
1460            new_input_notes,
1461            updated_input_notes,
1462            new_output_notes,
1463        ))
1464    }
1465}
1466
1467// TRANSACTION STORE UPDATE ERROR
1468// ================================================================================================
1469
1470/// Error returned by [`Client::get_transaction_store_update`] when building the store update
1471/// for a submitted transaction fails.
1472#[derive(Debug, thiserror::Error)]
1473pub enum TransactionStoreUpdateError {
1474    #[error("store error")]
1475    Store(#[from] StoreError),
1476    #[error("note screener error")]
1477    NoteScreener(#[from] NoteScreenerError),
1478    #[error("note record error")]
1479    NoteRecord(#[from] NoteRecordError),
1480}
1481
1482// HELPERS
1483// ================================================================================================
1484
1485#[derive(Clone, Copy, Debug)]
1486enum TransactionExecutionMode {
1487    Standard,
1488    #[cfg(feature = "dap")]
1489    Dap,
1490}
1491
1492/// Data-store-independent state produced during transaction preparation.
1493pub(crate) struct PreparedTransaction {
1494    pub(crate) notes: InputNotes<InputNote>,
1495    pub(crate) output_recipients: Vec<NoteRecipient>,
1496    pub(crate) future_notes: Vec<(NoteDetails, NoteTag)>,
1497    pub(crate) tx_args: TransactionArgs,
1498    pub(crate) foreign_account_inputs: Vec<AccountInputs>,
1499    pub(crate) block_num: BlockNumber,
1500    pub(crate) ignore_invalid_notes: bool,
1501}
1502
1503impl PreparedTransaction {
1504    /// Returns the scripts of the request's expected output notes. These must be registered on
1505    /// the executor's data store so output note creation can resolve them during execution.
1506    pub(crate) fn output_note_scripts(&self) -> impl Iterator<Item = NoteScript> + '_ {
1507        self.output_recipients.iter().map(|recipient| recipient.script().clone())
1508    }
1509}
1510
1511/// Helper to get the account outgoing assets.
1512///
1513/// Any outgoing assets resulting from executing note scripts but not present in expected output
1514/// notes wouldn't be included.
1515fn get_outgoing_assets(
1516    transaction_request: &TransactionRequest,
1517) -> (BTreeMap<AccountId, u64>, Vec<NonFungibleAsset>) {
1518    // Get own notes assets
1519    let mut own_notes_assets = match transaction_request.script_template() {
1520        Some(TransactionScriptTemplate::SendNotes(notes)) => notes
1521            .iter()
1522            .map(|note| (note.id(), note.assets().clone()))
1523            .collect::<BTreeMap<_, _>>(),
1524        _ => BTreeMap::default(),
1525    };
1526    // Get transaction output notes assets
1527    let mut output_notes_assets = transaction_request
1528        .expected_output_own_notes()
1529        .into_iter()
1530        .map(|note| (note.id(), note.assets().clone()))
1531        .collect::<BTreeMap<_, _>>();
1532
1533    // Merge with own notes assets and delete duplicates
1534    output_notes_assets.append(&mut own_notes_assets);
1535
1536    // Create a map of the fungible and non-fungible assets in the output notes
1537    let outgoing_assets = output_notes_assets.values().flat_map(|note_assets| note_assets.iter());
1538
1539    request::collect_assets(outgoing_assets)
1540}
1541
1542/// Commits fee conversion info paying the transaction fee in the chain's native fee asset at rate
1543/// 1/1, unless the account cannot read it.
1544///
1545/// Signature-based auth components abort when a non-zero `verification_base_fee` meets auth args
1546/// carrying no conversion info, so a request built without one is unexecutable rather than merely
1547/// suboptimal. Components that ignore the auth args settle their fee some other way and are left
1548/// alone, unless the request declares a salt such a component can never read (see
1549/// [`validate_fee_conversion_info_support`]).
1550///
1551/// The default salt is fixed because the signed transaction summary covers the auth args, and a
1552/// random salt would change the summary on every execution, breaking flows that reproduce one to
1553/// verify a signature over it. `AuthMultisig` is the mirror image: there the salt *is* the replay
1554/// guard, so the fixed one would eventually collide, and such a caller must declare a fresh one
1555/// with [`TransactionRequestBuilder::fee_conversion_salt`].
1556fn attach_native_fee_conversion_info(
1557    transaction_request: &mut TransactionRequest,
1558    account_code_interface: &AccountCodeInterface,
1559    reference_header: &BlockHeader,
1560) -> Result<(), ClientError> {
1561    // An auth arg the caller set is the caller's business: it may carry a commitment the caller
1562    // computed itself, or something else entirely. An empty word commits nothing, so it does not
1563    // count.
1564    if transaction_request.has_auth_arg() {
1565        return Ok(());
1566    }
1567
1568    let fee_parameters = reference_header.fee_parameters();
1569    let declared_salt = transaction_request.fee_conversion_salt();
1570    if fee_parameters.verification_base_fee() == 0 && declared_salt.is_none() {
1571        return Ok(());
1572    }
1573
1574    match FeeAuth::of(account_code_interface) {
1575        FeeAuth::FixedSalt => {
1576            transaction_request.commit_native_fee_conversion_info(
1577                fee_parameters.fee_faucet_id(),
1578                declared_salt.unwrap_or(NATIVE_FEE_CONVERSION_SALT),
1579            );
1580            Ok(())
1581        },
1582        FeeAuth::CallerChosenSalt(component) => match declared_salt {
1583            Some(salt) => {
1584                transaction_request
1585                    .commit_native_fee_conversion_info(fee_parameters.fee_faucet_id(), salt);
1586                Ok(())
1587            },
1588            None => Err(ClientError::TransactionRequestError(
1589                TransactionRequestError::FeeConversionInfoRequired(component),
1590            )),
1591        },
1592        FeeAuth::Ignored(component) => match declared_salt {
1593            // Batch execution skips `validate_account_request`, so the mismatch is caught here
1594            // too rather than silently dropping the declared salt.
1595            Some(_) => Err(ClientError::TransactionRequestError(
1596                TransactionRequestError::FeeConversionInfoUnsupported(component),
1597            )),
1598            None => Ok(()),
1599        },
1600    }
1601}
1602
1603/// How an account's auth component treats the transaction's auth argument where a fee is charged.
1604enum FeeAuth {
1605    /// Reads it as fee conversion info without constraining the salt, so the client's fixed
1606    /// default salt works where the caller declares none.
1607    FixedSalt,
1608    /// Reads it as conversion info too, but reuses the salt as a replay guard the caller must
1609    /// choose. Carries the component's name for the error.
1610    CallerChosenSalt(String),
1611    /// Does not read it as conversion info, so anything written there is ignored and the fee is
1612    /// settled some other way. Carries the auth component's name, or `"unrecognized"` when the
1613    /// client recognizes no auth component at all.
1614    Ignored(String),
1615}
1616
1617impl FeeAuth {
1618    /// Classifies the account's auth component.
1619    ///
1620    /// A single-sig component decides the answer wherever it sits in the component list, so the
1621    /// classification does not depend on the order components come back in.
1622    ///
1623    /// An unrecognized component is [`FeeAuth::Ignored`] and left alone: writing an argument such
1624    /// a component may read for its own purposes is worse than writing nothing. The component list
1625    /// is inspected directly because `AccountInterface::new` panics on exactly those components.
1626    fn of(account_code_interface: &AccountCodeInterface) -> Self {
1627        let procedures: Vec<_> = account_code_interface.procedures().iter().copied().collect();
1628        let components = AccountComponentInterface::from_procedures(&procedures);
1629
1630        if components
1631            .iter()
1632            .any(|component| matches!(component, AccountComponentInterface::AuthSingleSig))
1633        {
1634            return Self::FixedSalt;
1635        }
1636
1637        // Every multisig flavour whose MASM calls `fee::load_conversion_info` belongs here.
1638        // `multisig_smart.masm` and `guarded_multisig.masm` both `dupw` the auth argument, load the
1639        // conversion info out of it and keep the copy as the summary salt, so the salt is the
1640        // caller's replay guard in both.
1641        let caller_chosen_salt = components.iter().find_map(|component| match component {
1642            AccountComponentInterface::AuthMultisig
1643            | AccountComponentInterface::AuthMultisigSmart
1644            | AccountComponentInterface::AuthGuardedMultisig => {
1645                Some(Self::CallerChosenSalt(component.name()))
1646            },
1647            _ => None,
1648        });
1649
1650        caller_chosen_salt.unwrap_or_else(|| {
1651            let name = components
1652                .iter()
1653                .find(|component| {
1654                    matches!(
1655                        component,
1656                        AccountComponentInterface::AuthNoAuth
1657                            | AccountComponentInterface::AuthNetworkAccount
1658                    )
1659                })
1660                .map_or_else(|| "unrecognized".into(), AccountComponentInterface::name);
1661
1662            Self::Ignored(name)
1663        })
1664    }
1665}
1666
1667/// Returns the conversion info an account should commit to settle its fee in the native asset at
1668/// rate 1/1, or `None` when the account pays its fee some other way.
1669///
1670/// Shared with note screening so the two cannot disagree about what an account needs.
1671pub(crate) fn native_fee_conversion_info(
1672    account_code_interface: &AccountCodeInterface,
1673    fee_parameters: &FeeParameters,
1674) -> Option<FeeConversionInfo> {
1675    if fee_parameters.verification_base_fee() == 0 {
1676        return None;
1677    }
1678
1679    // Only a fixed salt can be paired with this info by anyone other than the caller: where the
1680    // salt is the account's replay guard, the caller is the one who has to choose it.
1681    match FeeAuth::of(account_code_interface) {
1682        FeeAuth::FixedSalt => Some(FeeConversionInfo::one_to_one(fee_parameters.fee_faucet_id())),
1683        FeeAuth::CallerChosenSalt(_) | FeeAuth::Ignored(_) => None,
1684    }
1685}
1686
1687/// Verifies that the account can consume fee conversion info passed through the auth args.
1688///
1689/// Only the signature-based auth components read the auth args as conversion info (through
1690/// `miden::standards::fee`). On any other auth component the declared salt would go unread and no
1691/// conversion info would be committed, so the request is rejected here instead.
1692fn validate_fee_conversion_info_support(
1693    transaction_request: &TransactionRequest,
1694    account_code_interface: &AccountCodeInterface,
1695) -> Result<(), ClientError> {
1696    if transaction_request.fee_conversion_salt().is_none() {
1697        return Ok(());
1698    }
1699
1700    match FeeAuth::of(account_code_interface) {
1701        FeeAuth::FixedSalt | FeeAuth::CallerChosenSalt(_) => Ok(()),
1702        FeeAuth::Ignored(auth_component) => Err(ClientError::TransactionRequestError(
1703            TransactionRequestError::FeeConversionInfoUnsupported(auth_component),
1704        )),
1705    }
1706}
1707/// Verifies that every output note emitted directly by the transaction declares `account_id` as
1708/// its sender.
1709///
1710/// A note's sender is bound by the kernel to the account that emits it, and note scripts (e.g.
1711/// P2IDE reclaim) authorize on that field, so an output note declaring a foreign sender can never
1712/// be executed. Catching it here yields a clear, immediate error instead of a cryptic failure deep
1713/// in transaction script building.
1714fn validate_output_note_senders(
1715    transaction_request: &TransactionRequest,
1716    account_id: AccountId,
1717) -> Result<(), ClientError> {
1718    for note in transaction_request.expected_output_own_notes() {
1719        let sender = note.metadata().sender();
1720        if sender != account_id {
1721            return Err(ClientError::TransactionRequestError(
1722                TransactionRequestError::OutputNoteSenderMismatch {
1723                    expected: account_id,
1724                    actual: sender,
1725                },
1726            ));
1727        }
1728    }
1729
1730    Ok(())
1731}
1732
1733/// Ensures a transaction request is compatible with the account's committed vault assets,
1734/// primarily by checking asset balances against the requested transfers.
1735fn validate_basic_account_request(
1736    transaction_request: &TransactionRequest,
1737    vault_assets: &[Asset],
1738) -> Result<(), ClientError> {
1739    // Get outgoing assets
1740    let (fungible_balance_map, non_fungible_set) = get_outgoing_assets(transaction_request);
1741
1742    // Get incoming assets
1743    let (incoming_fungible_balance_map, incoming_non_fungible_balance_set) =
1744        transaction_request.incoming_assets();
1745
1746    // Aggregate the account's fungible balance per faucet in one pass. A faucet's fungible asset
1747    // may occupy more than one callback-flag vault key, so all matching entries are summed.
1748    let mut available_fungible: BTreeMap<AccountId, u64> = BTreeMap::new();
1749    for asset in vault_assets {
1750        if let Asset::Fungible(fungible) = asset {
1751            let balance = available_fungible.entry(fungible.faucet_id()).or_default();
1752            *balance = balance.saturating_add(fungible.amount().as_u64());
1753        }
1754    }
1755
1756    // Check if the account balance plus incoming assets is greater than or equal to the
1757    // outgoing fungible assets
1758    for (faucet_id, amount) in fungible_balance_map {
1759        let account_asset_amount = available_fungible.get(&faucet_id).copied().unwrap_or(0);
1760        let incoming_balance = incoming_fungible_balance_map.get(&faucet_id).unwrap_or(&0);
1761        if account_asset_amount + incoming_balance < amount {
1762            return Err(ClientError::AssetError(AssetError::FungibleAssetAmountNotSufficient {
1763                minuend: account_asset_amount,
1764                subtrahend: amount,
1765            }));
1766        }
1767    }
1768
1769    // Check if the account balance plus incoming assets is greater than or equal to the
1770    // outgoing non fungible assets
1771    for non_fungible in &non_fungible_set {
1772        let held = vault_assets
1773            .iter()
1774            .any(|asset| matches!(asset, Asset::NonFungible(nf) if nf == non_fungible));
1775        if !held && !incoming_non_fungible_balance_set.contains(non_fungible) {
1776            return Err(ClientError::TransactionRequestError(
1777                TransactionRequestError::MissingNonFungibleAsset(non_fungible.faucet_id()),
1778            ));
1779        }
1780    }
1781
1782    Ok(())
1783}
1784
1785/// Fetches a foreign account's proof and details from the network, converts them into
1786/// [`AccountInputs`], and caches the returned code in the store for future requests.
1787///
1788/// Storage maps the node caps as oversized (returned truncated) are carried root-only in the
1789/// inputs; reads from them resolve lazily as per-key witnesses during execution.
1790///
1791/// # Errors
1792/// Fails if the account is private: the RPC does not return account details for them, causing
1793/// [`TransactionRequestError::ForeignAccountDataMissing`].
1794pub(crate) async fn fetch_public_account_inputs(
1795    store: &Arc<dyn Store>,
1796    rpc_api: &Arc<dyn NodeRpcClient>,
1797    account_id: AccountId,
1798    storage_requirements: AccountStorageRequirements,
1799    account_state_at: AccountStateAt,
1800) -> Result<AccountInputs, ClientError> {
1801    let known_code: Option<AccountCode> =
1802        store.get_foreign_account_code(vec![account_id]).await?.into_values().next();
1803
1804    // Tracked accounts skip the asset list when unchanged; untracked accounts fetch it in full
1805    // so asset reads need no execution-time RPC.
1806    let vault = store
1807        .get_account_header(account_id)
1808        .await?
1809        .map_or(VaultFetch::Always, |(header, ..)| {
1810            VaultFetch::IfChangedFrom(header.vault_root())
1811        });
1812
1813    let (_block_num, account_proof) = rpc_api
1814        .get_account(
1815            account_id,
1816            GetAccountRequest::new()
1817                .with_storage(StorageMapFetch::Slots(storage_requirements.clone()))
1818                .at(account_state_at)
1819                .with_known_code(known_code)
1820                .with_vault(vault),
1821        )
1822        .await?;
1823
1824    let account_inputs = request::account_proof_into_inputs(account_proof)?;
1825
1826    let _ = store
1827        .upsert_foreign_account_code(account_id, account_inputs.code().clone())
1828        .await
1829        .inspect_err(|err| {
1830            tracing::warn!(
1831                %account_id,
1832                %err,
1833                "Failed to persist foreign account code to store"
1834            );
1835        });
1836
1837    Ok(account_inputs)
1838}
1839
1840/// Promotes a submission failure whose outcome is unknown, attaching everything a retry needs. Any
1841/// other failure is a rejection the node issued deliberately and passes through unchanged.
1842fn promote_indeterminate_submission(
1843    err: RpcError,
1844    transaction: ProvenTransaction,
1845    transaction_inputs: TransactionInputs,
1846) -> ClientError {
1847    if !err.is_indeterminate_submission() {
1848        return ClientError::RpcError(err);
1849    }
1850
1851    ClientError::SubmissionOutcomeUnknown {
1852        transaction: Box::new(transaction),
1853        transaction_inputs: Box::new(transaction_inputs),
1854        source: err,
1855    }
1856}
1857
1858/// Extracts notes from [`RawOutputNotes`].
1859/// Used for:
1860/// - Checking the relevance of notes to save them as input notes.
1861/// - Validate hashes versus expected output notes after a transaction is executed.
1862pub fn notes_from_output(output_notes: &RawOutputNotes) -> impl Iterator<Item = &Note> {
1863    output_notes.iter().filter_map(|n| match n {
1864        RawOutputNote::Full(n) => Some(n),
1865        RawOutputNote::Partial(_) => None,
1866    })
1867}
1868
1869/// Validates that the executed transaction's output recipients match what was expected in the
1870/// transaction request.
1871pub(crate) fn validate_executed_transaction(
1872    executed_transaction: &ExecutedTransaction,
1873    expected_output_recipients: &[NoteRecipient],
1874) -> Result<(), ClientError> {
1875    let tx_output_recipient_digests = executed_transaction
1876        .output_notes()
1877        .iter()
1878        .filter_map(|n| n.recipient().map(NoteRecipient::digest))
1879        .collect::<Vec<_>>();
1880
1881    let missing_recipient_digest: Vec<Word> = expected_output_recipients
1882        .iter()
1883        .filter_map(|recipient| {
1884            (!tx_output_recipient_digests.contains(&recipient.digest()))
1885                .then_some(recipient.digest())
1886        })
1887        .collect();
1888
1889    if !missing_recipient_digest.is_empty() {
1890        return Err(ClientError::MissingOutputRecipients(missing_recipient_digest));
1891    }
1892
1893    Ok(())
1894}
1895
1896// TESTS
1897// ================================================================================================
1898
1899#[cfg(test)]
1900mod tests {
1901    use alloc::vec;
1902
1903    use miden_protocol::Word;
1904    use miden_protocol::account::auth::AuthSecretKey;
1905    use miden_protocol::account::{
1906        Account,
1907        AccountBuilder,
1908        AccountComponent,
1909        AccountComponentMetadata,
1910        AccountId,
1911        AccountType,
1912    };
1913    use miden_protocol::asset::FungibleAsset;
1914    use miden_protocol::block::{BlockHeader, BlockNumber, FeeParameters};
1915    use miden_protocol::crypto::rand::RandomCoin;
1916    use miden_protocol::note::{Note, NoteType};
1917    use miden_protocol::testing::account_id::{
1918        ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
1919        ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
1920        ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
1921        ACCOUNT_ID_SENDER,
1922    };
1923    use miden_protocol::testing::validator_keys::random_validator_set;
1924    use miden_standards::account::AccountBuilderSchemaCommitmentExt;
1925    use miden_standards::account::auth::{
1926        Approver,
1927        ApproverSet,
1928        AuthGuardedMultisig,
1929        AuthGuardedMultisigConfig,
1930        AuthMultisig,
1931        AuthMultisigConfig,
1932        AuthMultisigSmart,
1933        AuthMultisigSmartConfig,
1934        AuthSingleSig,
1935        FeeConversionInfo,
1936        GuardianConfig,
1937        NoAuth,
1938        commit_fee_conversion_info,
1939    };
1940    use miden_standards::account::wallets::BasicWallet;
1941    use miden_standards::note::P2idNote;
1942
1943    use super::{
1944        AccountComponentInterface,
1945        NATIVE_FEE_CONVERSION_SALT,
1946        TransactionRequest,
1947        TransactionRequestBuilder,
1948        attach_native_fee_conversion_info,
1949        validate_fee_conversion_info_support,
1950        validate_output_note_senders,
1951    };
1952    use crate::ClientError;
1953    use crate::assembly::CodeBuilder;
1954    use crate::auth::AuthSchemeId;
1955    use crate::transaction::TransactionRequestError;
1956
1957    fn own_note_with_sender(sender: AccountId) -> Note {
1958        let faucet_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap();
1959        let target_id =
1960            AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();
1961        let mut rng = RandomCoin::new(Word::default());
1962
1963        P2idNote::builder()
1964            .sender(sender)
1965            .target(target_id)
1966            .asset(FungibleAsset::new(faucet_id, 100).unwrap())
1967            .note_type(NoteType::Public)
1968            .generate_serial_number(&mut rng)
1969            .build()
1970            .expect("note creation failed")
1971            .into()
1972    }
1973
1974    #[test]
1975    fn output_note_with_foreign_sender_is_rejected() {
1976        let account_id =
1977            AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();
1978        let foreign_sender = AccountId::try_from(ACCOUNT_ID_SENDER).unwrap();
1979        assert_ne!(account_id, foreign_sender);
1980
1981        let request = TransactionRequestBuilder::new()
1982            .own_output_notes(vec![own_note_with_sender(foreign_sender)])
1983            .build()
1984            .unwrap();
1985
1986        let err = validate_output_note_senders(&request, account_id).unwrap_err();
1987        match err {
1988            ClientError::TransactionRequestError(
1989                TransactionRequestError::OutputNoteSenderMismatch { expected, actual },
1990            ) => {
1991                assert_eq!(expected, account_id);
1992                assert_eq!(actual, foreign_sender);
1993            },
1994            other => panic!("expected OutputNoteSenderMismatch, got {other:?}"),
1995        }
1996    }
1997
1998    #[test]
1999    fn output_note_with_matching_sender_is_accepted() {
2000        let account_id =
2001            AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();
2002
2003        let request = TransactionRequestBuilder::new()
2004            .own_output_notes(vec![own_note_with_sender(account_id)])
2005            .build()
2006            .unwrap();
2007
2008        validate_output_note_senders(&request, account_id).unwrap();
2009    }
2010
2011    #[test]
2012    fn request_without_own_output_notes_is_accepted() {
2013        let account_id =
2014            AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();
2015        let faucet_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap();
2016
2017        // A consume-only request (input note, no own output notes) must pass the sender check.
2018        let request = TransactionRequestBuilder::new()
2019            .input_notes(vec![(own_note_with_sender(faucet_id), None)])
2020            .build()
2021            .unwrap();
2022
2023        validate_output_note_senders(&request, account_id).unwrap();
2024    }
2025
2026    /// Builds an account carrying `auth_component` and a basic wallet.
2027    fn account_with_auth(auth_component: impl Into<AccountComponent>) -> Account {
2028        AccountBuilder::new([7u8; 32])
2029            .account_type(AccountType::Public)
2030            .with_component(auth_component)
2031            .with_component(BasicWallet)
2032            .build_with_schema_commitment()
2033            .expect("account creation failed")
2034    }
2035
2036    fn fee_conversion_request() -> TransactionRequest {
2037        TransactionRequestBuilder::new()
2038            .fee_conversion_salt(Word::from([13u32, 14, 15, 16]))
2039            .build()
2040            .unwrap()
2041    }
2042
2043    #[test]
2044    fn fee_conversion_info_is_accepted_by_a_signature_authenticated_account() {
2045        let key = AuthSecretKey::new_falcon512_poseidon2();
2046        let auth = AuthSingleSig::new(Approver::new(
2047            key.public_key().to_commitment(),
2048            AuthSchemeId::Falcon512Poseidon2,
2049        ));
2050
2051        validate_fee_conversion_info_support(
2052            &fee_conversion_request(),
2053            &account_with_auth(auth).code_interface(),
2054        )
2055        .unwrap();
2056    }
2057
2058    #[test]
2059    fn fee_conversion_info_is_rejected_by_an_account_that_cannot_read_it() {
2060        let account = account_with_auth(NoAuth);
2061
2062        let err = validate_fee_conversion_info_support(
2063            &fee_conversion_request(),
2064            &account.code_interface(),
2065        )
2066        .expect_err("NoAuth does not read the auth args");
2067        match err {
2068            ClientError::TransactionRequestError(
2069                TransactionRequestError::FeeConversionInfoUnsupported(auth_component),
2070            ) => assert_eq!(auth_component, AccountComponentInterface::AuthNoAuth.name()),
2071            other => panic!("expected FeeConversionInfoUnsupported, got {other:?}"),
2072        }
2073    }
2074
2075    #[test]
2076    fn a_request_without_fee_conversion_info_skips_the_auth_component_check() {
2077        // `NoAuth` cannot read conversion info, but a request that declares none is unaffected.
2078        validate_fee_conversion_info_support(
2079            &TransactionRequestBuilder::new().build().unwrap(),
2080            &account_with_auth(NoAuth).code_interface(),
2081        )
2082        .unwrap();
2083    }
2084
2085    // NATIVE FEE CONVERSION INFO INJECTION
2086    // --------------------------------------------------------------------------------------------
2087
2088    /// Fee faucet the headers below name, distinct from the faucet
2089    /// [`fee_conversion_request`] pays in so the two can be told apart.
2090    const NATIVE_FEE_FAUCET: u128 = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET;
2091
2092    /// Builds a block header whose fee parameters charge `verification_base_fee` in
2093    /// [`NATIVE_FEE_FAUCET`]'s asset.
2094    fn header_with_base_fee(verification_base_fee: u32) -> BlockHeader {
2095        let fee_parameters = FeeParameters::new(
2096            AccountId::try_from(NATIVE_FEE_FAUCET).unwrap(),
2097            verification_base_fee,
2098        );
2099        let (_, validator_keys) = random_validator_set(1);
2100
2101        BlockHeader::new(
2102            1,
2103            Word::empty(),
2104            BlockNumber::from(1u32),
2105            Word::empty(),
2106            Word::empty(),
2107            Word::empty(),
2108            Word::empty(),
2109            Word::empty(),
2110            Word::empty(),
2111            validator_keys,
2112            fee_parameters,
2113            0,
2114        )
2115    }
2116
2117    /// Returns the auth arg a request carries once the native conversion info has been attached
2118    /// against a header charging `verification_base_fee`.
2119    fn injected_auth_arg(
2120        mut request: TransactionRequest,
2121        account: &Account,
2122        verification_base_fee: u32,
2123    ) -> Option<Word> {
2124        let _ = attach_native_fee_conversion_info(
2125            &mut request,
2126            &account.code_interface(),
2127            &header_with_base_fee(verification_base_fee),
2128        );
2129        *request.auth_arg()
2130    }
2131
2132    /// As [`injected_auth_arg`], but surfaces the attachment error instead of discarding it.
2133    fn try_injected_auth_arg(
2134        mut request: TransactionRequest,
2135        account: &Account,
2136        verification_base_fee: u32,
2137    ) -> Result<Option<Word>, ClientError> {
2138        attach_native_fee_conversion_info(
2139            &mut request,
2140            &account.code_interface(),
2141            &header_with_base_fee(verification_base_fee),
2142        )?;
2143        Ok(*request.auth_arg())
2144    }
2145
2146    fn singlesig_account() -> Account {
2147        let key = AuthSecretKey::new_falcon512_poseidon2();
2148        account_with_auth(AuthSingleSig::new(Approver::new(
2149            key.public_key().to_commitment(),
2150            AuthSchemeId::Falcon512Poseidon2,
2151        )))
2152    }
2153
2154    fn guarded_multisig_account() -> Account {
2155        let approvers = ApproverSet::new(
2156            vec![Approver::new(
2157                AuthSecretKey::new_falcon512_poseidon2().public_key().to_commitment(),
2158                AuthSchemeId::Falcon512Poseidon2,
2159            )],
2160            1,
2161        )
2162        .unwrap();
2163
2164        let guardian = GuardianConfig::new(Approver::new(
2165            AuthSecretKey::new_falcon512_poseidon2().public_key().to_commitment(),
2166            AuthSchemeId::Falcon512Poseidon2,
2167        ));
2168
2169        account_with_auth(
2170            AuthGuardedMultisig::new(AuthGuardedMultisigConfig::new(approvers, guardian).unwrap())
2171                .unwrap(),
2172        )
2173    }
2174
2175    #[test]
2176    fn native_fee_conversion_info_is_attached_on_a_fee_charging_chain() {
2177        let auth_arg = injected_auth_arg(
2178            TransactionRequestBuilder::new().build().unwrap(),
2179            &singlesig_account(),
2180            500,
2181        )
2182        .expect("a fee-charging chain should get conversion info attached");
2183
2184        let (expected, _) = commit_fee_conversion_info(
2185            FeeConversionInfo::one_to_one(AccountId::try_from(NATIVE_FEE_FAUCET).unwrap()),
2186            NATIVE_FEE_CONVERSION_SALT,
2187        );
2188        assert_eq!(auth_arg, expected, "the fee should be paid in the native asset at rate 1/1");
2189    }
2190
2191    #[test]
2192    fn an_explicit_auth_arg_is_not_overwritten() {
2193        let auth_arg = Word::from([21u32, 22, 23, 24]);
2194        let request = TransactionRequestBuilder::new().auth_arg(auth_arg).build().unwrap();
2195
2196        assert_eq!(
2197            injected_auth_arg(request, &singlesig_account(), 500),
2198            Some(auth_arg),
2199            "a request that declares its own auth arg keeps it"
2200        );
2201    }
2202
2203    /// Expected commitment for the native 1/1 conversion info under `salt`.
2204    fn native_commitment(salt: Word) -> Word {
2205        let (auth_arg, _) = commit_fee_conversion_info(
2206            FeeConversionInfo::one_to_one(AccountId::try_from(NATIVE_FEE_FAUCET).unwrap()),
2207            salt,
2208        );
2209        auth_arg
2210    }
2211
2212    #[test]
2213    fn a_declared_salt_is_used_for_the_native_commitment() {
2214        let salt = Word::from([17u32, 18, 19, 20]);
2215        let request = TransactionRequestBuilder::new().fee_conversion_salt(salt).build().unwrap();
2216
2217        let auth_arg = injected_auth_arg(request, &singlesig_account(), 500)
2218            .expect("a declared salt should still get native conversion info attached");
2219
2220        assert_eq!(auth_arg, native_commitment(salt));
2221    }
2222
2223    fn multisig_account() -> Account {
2224        let approvers = ApproverSet::new(
2225            vec![Approver::new(
2226                AuthSecretKey::new_falcon512_poseidon2().public_key().to_commitment(),
2227                AuthSchemeId::Falcon512Poseidon2,
2228            )],
2229            1,
2230        )
2231        .unwrap();
2232
2233        account_with_auth(AuthMultisig::new(AuthMultisigConfig::new(approvers)).unwrap())
2234    }
2235
2236    #[test]
2237    fn nothing_is_attached_where_it_is_not_needed_or_not_readable() {
2238        for (case, account, base_fee) in [
2239            ("a zero base fee charges nothing", singlesig_account(), 0),
2240            ("NoAuth never reads the auth args", account_with_auth(NoAuth), 500),
2241            ("a multisig salt is its own replay guard", multisig_account(), 500),
2242        ] {
2243            assert_eq!(
2244                injected_auth_arg(
2245                    TransactionRequestBuilder::new().build().unwrap(),
2246                    &account,
2247                    base_fee
2248                ),
2249                None,
2250                "{case}"
2251            );
2252        }
2253    }
2254
2255    // GUARDED MULTISIG
2256    // --------------------------------------------------------------------------------------------
2257
2258    /// `guarded_multisig.masm` loads the conversion info out of the auth args and pays the fee with
2259    /// it, so a declared asset and rate are what the account pays with rather than something
2260    /// discarded and reinterpreted as the summary salt.
2261    #[test]
2262    fn fee_conversion_info_is_accepted_by_a_guarded_multisig_account() {
2263        validate_fee_conversion_info_support(
2264            &fee_conversion_request(),
2265            &guarded_multisig_account().code_interface(),
2266        )
2267        .expect("a guarded multisig reads the auth args as conversion info");
2268    }
2269
2270    /// `guarded_multisig.masm` reuses the auth args as the summary salt after loading the
2271    /// conversion info out of them, exactly as `multisig.masm` does, so the same reasoning applies:
2272    /// the salt is the caller's replay guard and the client cannot pick it.
2273    #[test]
2274    fn a_guarded_multisig_account_must_declare_its_own_fee_conversion_info() {
2275        let err = try_injected_auth_arg(
2276            TransactionRequestBuilder::new().build().unwrap(),
2277            &guarded_multisig_account(),
2278            500,
2279        )
2280        .expect_err("a guarded multisig account cannot inherit the fixed native salt");
2281        match err {
2282            ClientError::TransactionRequestError(
2283                TransactionRequestError::FeeConversionInfoRequired(auth_component),
2284            ) => {
2285                assert_eq!(auth_component, AccountComponentInterface::AuthGuardedMultisig.name());
2286            },
2287            other => panic!("expected FeeConversionInfoRequired, got {other:?}"),
2288        }
2289
2290        let salt = Word::from([13u32, 14, 15, 16]);
2291        assert_eq!(
2292            try_injected_auth_arg(fee_conversion_request(), &guarded_multisig_account(), 500)
2293                .expect("a declared salt is accepted"),
2294            Some(native_commitment(salt)),
2295            "a guarded multisig account that declares a salt commits the native conversion info"
2296        );
2297
2298        assert_eq!(
2299            try_injected_auth_arg(
2300                TransactionRequestBuilder::new().build().unwrap(),
2301                &guarded_multisig_account(),
2302                0
2303            )
2304            .expect("a chain charging nothing needs no conversion info"),
2305            None,
2306        );
2307    }
2308
2309    // SMART MULTISIG
2310    // --------------------------------------------------------------------------------------------
2311
2312    fn smart_multisig_account() -> Account {
2313        let approvers = ApproverSet::new(
2314            vec![Approver::new(
2315                AuthSecretKey::new_falcon512_poseidon2().public_key().to_commitment(),
2316                AuthSchemeId::Falcon512Poseidon2,
2317            )],
2318            1,
2319        )
2320        .unwrap();
2321
2322        account_with_auth(AuthMultisigSmart::new(AuthMultisigSmartConfig::new(approvers)).unwrap())
2323    }
2324
2325    /// As of `0.16.0-rc.9` `multisig_smart.masm` loads the conversion info out of the auth args and
2326    /// pays the fee with it, exactly as `guarded_multisig.masm` does, so a declared asset and rate
2327    /// are what the account pays with rather than something discarded and reinterpreted as the
2328    /// summary salt.
2329    #[test]
2330    fn fee_conversion_info_is_accepted_by_a_smart_multisig_account() {
2331        validate_fee_conversion_info_support(
2332            &fee_conversion_request(),
2333            &smart_multisig_account().code_interface(),
2334        )
2335        .expect("a smart multisig reads the auth args as conversion info");
2336    }
2337
2338    /// `multisig_smart.masm` reuses the auth args as the summary salt after loading the conversion
2339    /// info out of them, so the same reasoning as for the other multisig flavours applies: the salt
2340    /// is the caller's replay guard and the client cannot pick it.
2341    #[test]
2342    fn a_smart_multisig_account_must_declare_its_own_fee_conversion_info() {
2343        let err = try_injected_auth_arg(
2344            TransactionRequestBuilder::new().build().unwrap(),
2345            &smart_multisig_account(),
2346            500,
2347        )
2348        .expect_err("a smart multisig account cannot inherit the fixed native salt");
2349        match err {
2350            ClientError::TransactionRequestError(
2351                TransactionRequestError::FeeConversionInfoRequired(auth_component),
2352            ) => {
2353                assert_eq!(auth_component, AccountComponentInterface::AuthMultisigSmart.name());
2354            },
2355            other => panic!("expected FeeConversionInfoRequired, got {other:?}"),
2356        }
2357
2358        let salt = Word::from([13u32, 14, 15, 16]);
2359        assert_eq!(
2360            try_injected_auth_arg(fee_conversion_request(), &smart_multisig_account(), 500)
2361                .expect("a declared salt is accepted"),
2362            Some(native_commitment(salt)),
2363            "a smart multisig account that declares a salt commits the native conversion info"
2364        );
2365
2366        assert_eq!(
2367            try_injected_auth_arg(
2368                TransactionRequestBuilder::new().build().unwrap(),
2369                &smart_multisig_account(),
2370                0
2371            )
2372            .expect("a chain charging nothing needs no conversion info"),
2373            None,
2374        );
2375    }
2376
2377    /// An account carrying a custom auth component names no recognized one, which
2378    /// `AccountInterface::new` asserts on rather than reports.
2379    #[test]
2380    fn an_unrecognized_auth_component_is_rejected_rather_than_panicking() {
2381        const CUSTOM_AUTH: &str = "
2382            use miden::protocol::native_account
2383
2384            @auth_script
2385            pub proc auth_custom
2386                exec.native_account::incr_nonce
2387                drop
2388            end
2389        ";
2390
2391        let code = CodeBuilder::default()
2392            .compile_component_code("miden::testing::custom_auth", CUSTOM_AUTH)
2393            .expect("custom auth component code should compile");
2394        let auth = AccountComponent::new(
2395            code,
2396            vec![],
2397            AccountComponentMetadata::new("miden::testing::custom_auth"),
2398        )
2399        .expect("custom auth component");
2400
2401        let account = account_with_auth(auth);
2402
2403        let err = validate_fee_conversion_info_support(
2404            &fee_conversion_request(),
2405            &account.code_interface(),
2406        )
2407        .expect_err("an account with no recognized auth component cannot read conversion info");
2408        assert!(matches!(
2409            err,
2410            ClientError::TransactionRequestError(
2411                TransactionRequestError::FeeConversionInfoUnsupported(_)
2412            )
2413        ));
2414
2415        assert_eq!(
2416            try_injected_auth_arg(TransactionRequestBuilder::new().build().unwrap(), &account, 500)
2417                .expect("a request declaring nothing is left alone"),
2418            None,
2419            "an auth component nothing can reason about gets nothing attached"
2420        );
2421    }
2422}