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