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;
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::protocol_config::ProtocolConfig;
85use miden_protocol::transaction::{AccountInputs, PartialBlockchain};
86use miden_protocol::vm::MIN_STACK_DEPTH;
87use miden_protocol::{Felt, Word};
88use miden_standards::account::auth::FeeConversionInfo;
89use miden_standards::account::faucets::FungibleFaucet;
90use miden_standards::account::interface::AccountComponentInterfaceExt;
91use miden_standards::note::TxFeeNote;
92use miden_tx::{DataStore, NoteConsumptionChecker, TransactionExecutor};
93use tracing::info;
94
95use super::Client;
96use crate::ClientError;
97use crate::note::{NoteScreenerError, NoteUpdateTracker, StandardNote};
98use crate::rpc::domain::account::{
99    AccountStorageRequirements,
100    GetAccountRequest,
101    StorageMapFetch,
102    VaultFetch,
103};
104use crate::rpc::encryption::{TransactionEncryptionKey, seal_transaction_inputs};
105use crate::rpc::{AccountStateAt, NodeRpcClient, RpcError};
106use crate::store::data_store::{ClientDataStore, build_partial_mmr_with_paths};
107use crate::store::input_note_states::ExpectedNoteState;
108use crate::store::{
109    AccountRecord,
110    InputNoteRecord,
111    InputNoteState,
112    NoteFilter,
113    NoteRecordError,
114    OutputNoteRecord,
115    Store,
116    StoreError,
117    TransactionFilter,
118};
119use crate::sync::NoteTagRecord;
120use crate::transaction::batch::InMemoryBatchDataStore;
121
122pub mod batch;
123pub use batch::{BatchBuilder, BatchBuilderError};
124
125mod chain_anchor;
126pub use chain_anchor::{ChainAnchor, ChainAnchorError};
127
128#[cfg(any())]
129mod dap_executor;
130mod prover;
131pub use prover::TransactionProver;
132
133mod record;
134pub use record::{
135    DiscardCause,
136    TransactionDetails,
137    TransactionRecord,
138    TransactionStatus,
139    TransactionStatusVariant,
140};
141
142mod store_update;
143pub use store_update::TransactionStoreUpdate;
144
145mod request;
146pub use request::{
147    ForeignAccount,
148    NoteArgs,
149    PaymentNoteDescription,
150    PswapTransactionData,
151    SwapTransactionData,
152    TransactionRequest,
153    TransactionRequestBuilder,
154    TransactionRequestError,
155    TransactionScriptTemplate,
156    build_fpi_script,
157};
158
159mod observer;
160pub use observer::TransactionObserver;
161
162mod result;
163// RE-EXPORTS
164// ================================================================================================
165pub use miden_protocol::transaction::{
166    ExecutedTransaction,
167    InputNote,
168    InputNotes,
169    OutputNote,
170    OutputNotes,
171    ProvenTransaction,
172    PublicOutputNote,
173    RawOutputNote,
174    RawOutputNotes,
175    TransactionArgs,
176    TransactionFee,
177    TransactionFeeError,
178    TransactionId,
179    TransactionInputs,
180    TransactionKernel,
181    TransactionScript,
182    TransactionScriptRoot,
183    TransactionSummary,
184};
185pub use miden_protocol::vm::{AdviceInputs, AdviceMap};
186pub use miden_standards::account::interface::{AccountComponentInterface, AccountInterface};
187pub use miden_standards::tx_script::{
188    ExpirationTransactionScript,
189    SendNotesTransactionScriptError,
190};
191pub use miden_tx::auth::TransactionAuthenticator;
192pub use miden_tx::{
193    DataStoreError,
194    LocalTransactionProver,
195    Prover,
196    TransactionExecutorError,
197    TransactionProverError,
198};
199pub use result::TransactionResult;
200
201// CONSTANTS
202// ================================================================================================
203
204/// Salt the client commits native fee conversion info under when the request declares none.
205///
206/// See [`attach_native_fee_conversion_info`] for why this is a constant.
207pub(crate) const NATIVE_FEE_CONVERSION_SALT: Word = Word::empty();
208
209/// Transaction management methods
210impl<AUTH> Client<AUTH>
211where
212    AUTH: TransactionAuthenticator + Sync + 'static,
213{
214    // TRANSACTION DATA RETRIEVAL
215    // --------------------------------------------------------------------------------------------
216
217    /// Retrieves tracked transactions, filtered by [`TransactionFilter`].
218    pub async fn get_transactions(
219        &self,
220        filter: TransactionFilter,
221    ) -> Result<Vec<TransactionRecord>, ClientError> {
222        self.store.get_transactions(filter).await.map_err(Into::into)
223    }
224
225    // TRANSACTION BATCH
226    // --------------------------------------------------------------------------------------------
227
228    /// Open a new [`BatchBuilder`] for accumulating transactions across one or more local accounts.
229    ///
230    /// See [`crate::transaction::batch`] for usage and constraints.
231    pub fn new_transaction_batch(&mut self) -> BatchBuilder<'_, AUTH> {
232        let inner_data_store = ClientDataStore::new(self.store.clone(), self.rpc_api.clone());
233        BatchBuilder {
234            client: self,
235            data_store: InMemoryBatchDataStore::new(inner_data_store),
236            pushed_txs: Vec::new(),
237            consumed_input_notes: BTreeSet::new(),
238        }
239    }
240
241    // TRANSACTION
242    // --------------------------------------------------------------------------------------------
243
244    /// Executes a transaction specified by the request against the specified account, proves it,
245    /// submits it to the network, and updates the local database.
246    ///
247    /// Uses the client's default prover (configured via [`crate::builder::ClientBuilder::prover`]).
248    pub async fn submit_new_transaction(
249        &mut self,
250        account_id: AccountId,
251        transaction_request: TransactionRequest,
252    ) -> Result<TransactionId, ClientError> {
253        let prover = self.tx_prover.clone();
254        self.submit_new_transaction_with_prover(account_id, transaction_request, prover)
255            .await
256    }
257
258    /// Executes a transaction specified by the request against the specified account, proves it
259    /// with the provided prover, submits it to the network, and updates the local database.
260    ///
261    /// This is useful for falling back to a different prover (e.g., local) when the default prover
262    /// (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. The registration path
270        // 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 is a separate step
288        // that can fail independently. On failure, return a distinct error carrying the pending
289        // update so the caller can decide how to recover (re-apply later via
290        // `apply_transaction_update`, persist for the next session, etc.).
291        //
292        // The update is boxed so it does not inflate the enclosing future across await points
293        // (triggers clippy::large_futures).
294        let tx_update =
295            Box::new(self.get_transaction_store_update(&tx_result, submission_height).await?);
296
297        if let Err(apply_err) = self.apply_transaction_update((*tx_update).clone()).await {
298            info!(
299                "apply_transaction_update failed for submitted tx {tx_id}; returning \
300                 ApplyTransactionAfterSubmitFailed with the pending update attached: {apply_err}"
301            );
302            return Err(ClientError::ApplyTransactionAfterSubmitFailed {
303                pending_update: tx_update,
304                source: Box::new(apply_err),
305            });
306        }
307
308        // Fire transaction observers (mirrors `apply_transaction`). Per-observer failures are
309        // logged and never propagate — they're feature-specific side-channels, not part of the
310        // submit contract.
311        for observer in &self.transaction_observers {
312            crate::errors::log_observer_failure(
313                observer.name(),
314                "TransactionObserver::apply",
315                observer.apply(&tx_result).await,
316            );
317        }
318
319        Ok(tx_id)
320    }
321
322    /// Creates and executes a transaction specified by the request against the specified account,
323    /// but doesn't change the local database.
324    ///
325    /// # Errors
326    ///
327    /// - Returns [`ClientError::MissingOutputRecipients`] if the [`TransactionRequest`] output
328    ///   notes are not a subset of executor's output notes.
329    /// - Returns a [`ClientError::TransactionExecutorError`] if the execution fails.
330    /// - Returns a [`ClientError::TransactionRequestError`] if the request is invalid.
331    pub async fn execute_transaction(
332        &self,
333        account_id: AccountId,
334        transaction_request: TransactionRequest,
335    ) -> Result<TransactionResult, ClientError> {
336        Box::pin(self.execute_transaction_with_mode(
337            account_id,
338            transaction_request,
339            TransactionExecutionMode::Standard,
340            None,
341        ))
342        .await
343    }
344
345    /// Creates and executes a transaction specified by the request against the specified account,
346    /// using the provided [`ChainAnchor`] as the reference block instead of the current sync
347    /// height. Like [`Self::execute_transaction`], it doesn't change the local database.
348    ///
349    /// Since protocol 0.16 the signed transaction summary binds the reference block commitment, so
350    /// signatures collected over a summary only authorize an execution whose reference block is the
351    /// one the summary was built at. This method makes such an execution reproducible on any
352    /// client, regardless of its sync height: the anchor supplies the reference block header and a
353    /// consistent [`PartialBlockchain`], typically captured by the transaction's original proposer
354    /// via [`Self::chain_anchor_for_request`] and shipped alongside the signed data.
355    ///
356    /// The anchor pins the reference block only. The mode each input note is consumed in also
357    /// enters the summary, so a request shared across clients should pin it through
358    /// [`TransactionRequestBuilder::explicit_input_notes`]. Otherwise each client classifies the
359    /// notes from its own store, and two clients can commit to different input notes.
360    ///
361    /// Callers holding an anchor from an untrusted source should first compare
362    /// [`ChainAnchor::block_commitment`] against an independently trusted value (e.g. the block
363    /// commitment bound into the signed transaction summary).
364    ///
365    /// Foreign account proofs are fetched at the anchor's block, so requests with foreign accounts
366    /// additionally require the node to serve account state at that block.
367    ///
368    /// # Errors
369    ///
370    /// In addition to the [`Self::execute_transaction`] errors:
371    /// - Returns [`ClientError::ChainAnchorError`] if an authenticated input note's creation block
372    ///   is not tracked by the anchor.
373    /// - Returns a [`ClientError::TransactionExecutorError`] if an input note was created after the
374    ///   anchored reference block.
375    /// - Returns [`ChainAnchorError::AnchoredTransactionExpired`] if the executed transaction's
376    ///   expiration block has already been reached, which the network would reject.
377    pub async fn execute_transaction_at(
378        &mut self,
379        account_id: AccountId,
380        transaction_request: TransactionRequest,
381        anchor: ChainAnchor,
382    ) -> Result<TransactionResult, ClientError> {
383        let result = self
384            .execute_transaction_with_mode(
385                account_id,
386                transaction_request,
387                TransactionExecutionMode::Standard,
388                Some(Box::new(anchor)),
389            )
390            .await?;
391
392        // The expiration delta counts from the anchored reference block, so a stale anchor can
393        // yield an already-expired transaction, which the network would only reject after the
394        // caller has paid for proving. The sync height never runs ahead of the real tip, so this
395        // fires only on transactions that are certainly too late.
396        let expiration = result.executed_transaction().expiration_block_num();
397        let sync_height = self.store.get_sync_height().await?;
398        if expiration <= sync_height {
399            return Err(
400                ChainAnchorError::AnchoredTransactionExpired { expiration, sync_height }.into()
401            );
402        }
403
404        Ok(result)
405    }
406
407    /// Captures a [`ChainAnchor`] at the client's current sync height, tracking the blocks in
408    /// `tracked_blocks` (in addition to the reference block itself, which needs no tracking) so
409    /// that transactions consuming authenticated notes created in those blocks can later execute
410    /// against the anchor.
411    async fn chain_anchor_at_tip(
412        &self,
413        tracked_blocks: BTreeSet<BlockNumber>,
414    ) -> Result<ChainAnchor, ClientError> {
415        let sync_height = self.store.get_sync_height().await?;
416
417        let (header, _had_notes) = self
418            .store
419            .get_block_header_by_num(sync_height)
420            .await?
421            .ok_or(StoreError::BlockHeaderNotFound(sync_height))?;
422
423        let mut tracked_blocks = tracked_blocks;
424        // The kernel extends the MMR with the reference block itself, so it needs no path.
425        tracked_blocks.remove(&sync_height);
426
427        let block_headers: Vec<BlockHeader> = self
428            .store
429            .get_block_headers(&tracked_blocks)
430            .await?
431            .into_iter()
432            .map(|(header, _has_notes)| header)
433            .collect();
434
435        // `Store::get_block_headers` may silently omit missing headers, so verify each requested
436        // block is present rather than comparing lengths.
437        let fetched_nums: BTreeSet<BlockNumber> =
438            block_headers.iter().map(BlockHeader::block_num).collect();
439        if let Some(&missing) = tracked_blocks.difference(&fetched_nums).next() {
440            return Err(StoreError::BlockHeaderNotFound(missing).into());
441        }
442
443        let peaks = self.store.get_current_blockchain_peaks().await?;
444        let partial_mmr = build_partial_mmr_with_paths(&self.store, peaks, &block_headers).await?;
445
446        let chain = PartialBlockchain::new(partial_mmr, block_headers)?;
447
448        Ok(ChainAnchor::new(header, chain)?)
449    }
450
451    /// Captures a [`ChainAnchor`] at the client's current sync height, tracking the creation blocks
452    /// of the request's authenticated input notes so that the request can later execute against the
453    /// anchor. This covers notes the store holds as authenticated and notes pinned as authenticated
454    /// through [`TransactionRequestBuilder::explicit_input_notes`].
455    ///
456    /// This is the capture entry point for flows that never see a successful execution result at
457    /// capture time — e.g. multisig proposal flows, where execution intentionally fails with
458    /// [`TransactionExecutorError::Unauthorized`] to surface the transaction summary for signing.
459    /// Capture the anchor first, execute the request with [`Self::execute_transaction_at`], and
460    /// ship the anchor alongside the summary; the same anchor then reproduces the summary during
461    /// later verification and execution.
462    ///
463    /// # Errors
464    ///
465    /// - Returns [`ClientError::StoreError`] if a header for the sync height or a tracked block is
466    ///   not present in the store.
467    /// - Returns [`ChainAnchorError::TooManyTrackedBlocks`] if the request's authenticated input
468    ///   notes were created across more blocks than a transaction can reference.
469    pub async fn chain_anchor_for_request(
470        &self,
471        transaction_request: &TransactionRequest,
472    ) -> Result<ChainAnchor, ClientError> {
473        let inferred_input_note_ids: Vec<NoteId> = transaction_request
474            .input_note_ids()
475            .filter(|note_id| !transaction_request.explicit_input_notes.contains_key(note_id))
476            .collect();
477
478        let mut tracked_blocks: BTreeSet<BlockNumber> = if inferred_input_note_ids.is_empty() {
479            BTreeSet::new()
480        } else {
481            self.store
482                .get_input_notes(NoteFilter::List(inferred_input_note_ids))
483                .await?
484                .iter()
485                .filter(|record| record.is_authenticated())
486                .filter_map(|record| record.inclusion_proof())
487                .map(|proof| proof.location().block_num())
488                .collect()
489        };
490        tracked_blocks.extend(
491            transaction_request
492                .explicit_input_notes
493                .values()
494                .filter_map(InputNote::proof)
495                .map(|proof| proof.location().block_num()),
496        );
497
498        self.chain_anchor_at_tip(tracked_blocks).await
499    }
500
501    /// Executes `transaction_request` (e.g. consuming a note) through the DAP program executor, so
502    /// a DAP client can attach and step through the whole transaction — kernel, note scripts, and
503    /// account code — instead of only a standalone transaction script.
504    ///
505    /// This is a debugging entry point: it runs the transaction interactively under the debug
506    /// adapter and does not prove, submit, or apply the result. The listen address (and optional
507    /// replay-snapshot path) are taken from the globally installed
508    /// [`DapConfig`](miden_debug::DapConfig).
509    ///
510    /// # Errors
511    ///
512    /// This applies the same request preparation and output-recipient validation as
513    /// [`Self::execute_transaction`], and returns the corresponding [`ClientError`] on failure.
514    #[cfg(any())]
515    pub async fn execute_transaction_with_dap(
516        &self,
517        account_id: AccountId,
518        transaction_request: TransactionRequest,
519    ) -> Result<TransactionResult, ClientError> {
520        self.execute_transaction_with_mode(
521            account_id,
522            transaction_request,
523            TransactionExecutionMode::Dap,
524            None,
525        )
526        .await
527    }
528
529    /// Executes a prepared transaction with the selected program executor while keeping request
530    /// preparation, data-store population, note filtering, and result validation identical across
531    /// execution modes.
532    async fn execute_transaction_with_mode(
533        &self,
534        account_id: AccountId,
535        transaction_request: TransactionRequest,
536        execution_mode: TransactionExecutionMode,
537        anchor: Option<Box<ChainAnchor>>,
538    ) -> Result<TransactionResult, ClientError> {
539        let account: PartialAccount =
540            self.get_native_account_record(account_id).await?.try_into()?;
541
542        let prep = self
543            .prepare_transaction(&account, transaction_request, anchor.as_deref())
544            .await?;
545
546        let mut data_store = ClientDataStore::new(self.store.clone(), self.rpc_api.clone());
547        if let Some(anchor) = anchor {
548            data_store = data_store.with_chain_anchor(*anchor);
549        }
550        data_store.register_note_scripts(prep.output_note_scripts());
551        for fpi_account in &prep.foreign_account_inputs {
552            data_store.mast_store().load_account_code(fpi_account.code());
553        }
554        data_store.register_foreign_account_inputs(prep.foreign_account_inputs);
555
556        data_store.mast_store().load_account_code(account.code());
557
558        let mut notes = prep.notes;
559        if prep.ignore_invalid_notes {
560            notes = self
561                .get_valid_input_notes(
562                    &data_store,
563                    account.id(),
564                    prep.block_num,
565                    notes,
566                    prep.tx_args.clone(),
567                )
568                .await?;
569        }
570
571        let executed_transaction = match execution_mode {
572            TransactionExecutionMode::Standard => {
573                self.build_executor(&data_store)?
574                    .execute_transaction(account_id, prep.block_num, notes, prep.tx_args)
575                    .await?
576            },
577            #[cfg(any())]
578            TransactionExecutionMode::Dap => {
579                self.build_dap_executor(&data_store)?
580                    .execute_transaction(account_id, prep.block_num, notes, prep.tx_args)
581                    .await?
582            },
583        };
584
585        validate_executed_transaction(&executed_transaction, &prep.output_recipients)?;
586        TransactionResult::new(executed_transaction, prep.future_notes)
587    }
588
589    /// Performs the data-store-independent setup shared by `execute_transaction` and
590    /// `execute_transaction_for_batch`: validates the request against the account's committed store
591    /// state, loads/filters input notes, builds the transaction script and args, retrieves
592    /// foreign-account inputs, and computes the reference block number.
593    ///
594    /// This method does not write to the store: any state produced by the transaction is persisted
595    /// only after the transaction executes successfully.
596    ///
597    /// In batch execution, request validation is skipped: the committed store state does not
598    /// reflect balances stacked by prior in-batch pushes, so validating against it would wrongly
599    /// reject transactions the executor accepts.
600    ///
601    /// When `anchor` is provided, the reference block is the anchor's block instead of the current
602    /// sync height, and the recency check is skipped — anchored execution deliberately references a
603    /// block older than the tip.
604    pub(crate) async fn prepare_transaction(
605        &self,
606        account: &PartialAccount,
607        transaction_request: TransactionRequest,
608        anchor: Option<&ChainAnchor>,
609    ) -> Result<PreparedTransaction, ClientError> {
610        self.validate_account_request(
611            &transaction_request,
612            account.id(),
613            &account.code_interface(),
614        )
615        .await?;
616
617        self.prepare_transaction_inner(account.code_interface(), transaction_request, anchor)
618            .await
619    }
620
621    pub(crate) async fn prepare_transaction_for_batch(
622        &self,
623        account: &PartialAccount,
624        transaction_request: TransactionRequest,
625    ) -> Result<PreparedTransaction, ClientError> {
626        self.prepare_transaction_inner(account.code_interface(), transaction_request, None)
627            .await
628    }
629
630    async fn prepare_transaction_inner(
631        &self,
632        account_code_interface: AccountCodeInterface,
633        mut transaction_request: TransactionRequest,
634        anchor: Option<&ChainAnchor>,
635    ) -> Result<PreparedTransaction, ClientError> {
636        if anchor.is_none() {
637            self.validate_recency().await?;
638        }
639
640        // Retrieve all input notes from the store.
641        let mut stored_note_records = self
642            .store
643            .get_input_notes(NoteFilter::List(transaction_request.input_note_ids().collect()))
644            .await?;
645
646        // Verify that none of the stored input notes are already consumed.
647        for note in &stored_note_records {
648            if note.is_consumed() {
649                return Err(ClientError::TransactionRequestError(
650                    TransactionRequestError::InputNoteAlreadyConsumed(note.details_commitment()),
651                ));
652            }
653        }
654
655        // Only keep authenticated input notes from the store.
656        stored_note_records.retain(InputNoteRecord::is_authenticated);
657
658        let notes = transaction_request.build_input_notes(stored_note_records)?;
659
660        // Each authenticated note's creation block must be tracked by the anchor; fail with a typed
661        // error so callers can recapture a wider anchor. Notes newer than the anchor are left for
662        // the executor to reject.
663        if let Some(anchor) = anchor {
664            for note in notes.iter() {
665                if let Some(location) = note.location() {
666                    let block_num = location.block_num();
667                    if block_num < anchor.block_num()
668                        && !anchor.partial_blockchain().contains_block(block_num)
669                    {
670                        return Err(ChainAnchorError::BlockNotTracked { block_num }.into());
671                    }
672                }
673            }
674        }
675
676        let output_recipients =
677            transaction_request.expected_output_recipients().cloned().collect::<Vec<_>>();
678
679        let future_notes: Vec<(NoteDetails, NoteTag)> =
680            transaction_request.expected_future_notes().cloned().collect();
681
682        let tx_script = transaction_request.build_transaction_script(&account_code_interface)?;
683
684        let foreign_accounts = transaction_request.foreign_accounts().clone();
685
686        // The reference block: the anchor's block when pinned, the sync height otherwise. Foreign
687        // account proofs are fetched at this block to stay consistent with it.
688        let block_num = match anchor {
689            Some(anchor) => anchor.block_num(),
690            None => self.store.get_sync_height().await?,
691        };
692
693        let foreign_account_inputs =
694            self.retrieve_foreign_account_inputs(foreign_accounts, block_num).await?;
695
696        let ignore_invalid_notes = transaction_request.ignore_invalid_input_notes();
697
698        let reference_header = match anchor {
699            Some(anchor) => anchor.header().clone(),
700            None => {
701                self.store
702                    .get_block_header_by_num(block_num)
703                    .await?
704                    .ok_or(StoreError::BlockHeaderNotFound(block_num))?
705                    .0
706            },
707        };
708        attach_native_fee_conversion_info(
709            &mut transaction_request,
710            &account_code_interface,
711            &reference_header,
712            &self.get_protocol_config(reference_header.protocol_config_commitment()).await?,
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 id) is
755        // derived from `tx_result`, so a proof of anything else would be submitted while the local
756        // 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 output
759        // note commitments; the account commitments in turn commit to the account id, so a matching
760        // 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 node
781    /// 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_config().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) via
895        // 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, updating account data,
921    /// 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        // The transaction was proven and submitted to the node, so its note details and account
950        // update can be persisted.
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 connections,
986    /// allowing interactive debugging via any DAP-compatible client.
987    #[cfg(any())]
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 that
1067    /// 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 never
1072    /// 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 you
1076    /// 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 output
1127    /// note scripts registered, so output note creation can resolve them without them being present
1128    /// in the store.
1129    ///
1130    /// The trial runs against `data_store` at `block_ref`, which must match the reference block the
1131    /// 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 a
1183    /// 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, authenticator,
1261    /// 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 watched.
1277    /// 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(any())]
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 before
1396        // the transaction succeeds) are tracked from this point on, so records for them are built
1397        // 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 for a
1443/// 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(any())]
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 the
1477    /// 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<Asset>) {
1490    let mut own_notes_assets = match transaction_request.script_template() {
1491        Some(TransactionScriptTemplate::SendNotes(notes)) => notes
1492            .iter()
1493            .map(|note| (note.id(), note.assets().clone()))
1494            .collect::<BTreeMap<_, _>>(),
1495        _ => BTreeMap::default(),
1496    };
1497    let mut output_notes_assets = transaction_request
1498        .expected_output_own_notes()
1499        .into_iter()
1500        .map(|note| (note.id(), note.assets().clone()))
1501        .collect::<BTreeMap<_, _>>();
1502
1503    // Merge with own notes assets and delete duplicates
1504    output_notes_assets.append(&mut own_notes_assets);
1505
1506    // Create a map of the fungible and non-fungible assets in the output notes
1507    let outgoing_assets = output_notes_assets.values().flat_map(|note_assets| note_assets.iter());
1508
1509    request::collect_assets(outgoing_assets)
1510}
1511
1512/// Commits fee conversion info paying the transaction fee in the chain's native fee asset at rate
1513/// 1/1, unless the account cannot read it.
1514///
1515/// Signature-based auth components abort when a non-zero `verification_base_fee` meets auth args
1516/// carrying no conversion info, so a request built without one is unexecutable rather than merely
1517/// suboptimal. Components that ignore the auth args settle their fee some other way and are left
1518/// alone, unless the request declares a salt such a component can never read (see
1519/// [`validate_fee_conversion_info_support`]).
1520///
1521/// The default salt is fixed because the signed transaction summary covers the auth args, and a
1522/// random salt would change the summary on every execution, breaking flows that reproduce one to
1523/// verify a signature over it. `AuthMultisig` is the mirror image: there the salt *is* the replay
1524/// guard, so the fixed one would eventually collide, and such a caller must declare a fresh one
1525/// with [`TransactionRequestBuilder::fee_conversion_salt`].
1526fn attach_native_fee_conversion_info(
1527    transaction_request: &mut TransactionRequest,
1528    account_code_interface: &AccountCodeInterface,
1529    reference_header: &BlockHeader,
1530    protocol_config: &ProtocolConfig,
1531) -> Result<(), ClientError> {
1532    // An auth arg the caller set is the caller's business: it may carry a commitment the caller
1533    // computed itself, or something else entirely. An empty word commits nothing, so it does not
1534    // count.
1535    if transaction_request.has_auth_arg() {
1536        return Ok(());
1537    }
1538
1539    let fee_parameters = reference_header.fee_parameters();
1540    let declared_salt = transaction_request.fee_conversion_salt();
1541    if fee_parameters.verification_base_fee() == 0 && declared_salt.is_none() {
1542        return Ok(());
1543    }
1544
1545    match FeeAuth::of(account_code_interface) {
1546        FeeAuth::FixedSalt => {
1547            transaction_request.commit_native_fee_conversion_info(
1548                protocol_config.fee_asset_id().faucet_id(),
1549                declared_salt.unwrap_or(NATIVE_FEE_CONVERSION_SALT),
1550            );
1551            Ok(())
1552        },
1553        FeeAuth::CallerChosenSalt(component) => match declared_salt {
1554            Some(salt) => {
1555                transaction_request.commit_native_fee_conversion_info(
1556                    protocol_config.fee_asset_id().faucet_id(),
1557                    salt,
1558                );
1559                Ok(())
1560            },
1561            None => Err(ClientError::TransactionRequestError(
1562                TransactionRequestError::FeeConversionInfoRequired(component),
1563            )),
1564        },
1565        FeeAuth::Ignored(component) => match declared_salt {
1566            // Batch execution skips `validate_account_request`, so the mismatch is caught here too
1567            // rather than silently dropping the declared salt.
1568            Some(_) => Err(ClientError::TransactionRequestError(
1569                TransactionRequestError::FeeConversionInfoUnsupported(component),
1570            )),
1571            None => Ok(()),
1572        },
1573    }
1574}
1575
1576/// How an account's auth component treats the transaction's auth argument where a fee is charged.
1577enum FeeAuth {
1578    /// Reads it as fee conversion info without constraining the salt, so the client's fixed default
1579    /// salt works where the caller declares none.
1580    FixedSalt,
1581    /// Reads it as conversion info too, but reuses the salt as a replay guard the caller must
1582    /// choose. Carries the component's name for the error.
1583    CallerChosenSalt(String),
1584    /// Does not read it as conversion info, so anything written there is ignored and the fee is
1585    /// settled some other way. Carries the auth component's name, or `"unrecognized"` when the
1586    /// client recognizes no auth component at all.
1587    Ignored(String),
1588}
1589
1590impl FeeAuth {
1591    /// Classifies the account's auth component.
1592    ///
1593    /// A single-sig component decides the answer wherever it sits in the component list, so the
1594    /// classification does not depend on the order components come back in.
1595    ///
1596    /// An unrecognized component is [`FeeAuth::Ignored`] and left alone: writing an argument such a
1597    /// component may read for its own purposes is worse than writing nothing. The component list is
1598    /// inspected directly because `AccountInterface::new` panics on exactly those components.
1599    fn of(account_code_interface: &AccountCodeInterface) -> Self {
1600        let procedures: Vec<_> = account_code_interface.procedures().iter().copied().collect();
1601        let components = AccountComponentInterface::from_procedures(&procedures);
1602
1603        if components
1604            .iter()
1605            .any(|component| matches!(component, AccountComponentInterface::AuthSingleSig))
1606        {
1607            return Self::FixedSalt;
1608        }
1609
1610        // Every multisig flavour whose MASM calls `fee::load_conversion_info` belongs here.
1611        // `multisig_smart.masm` and `guarded_multisig.masm` both `dupw` the auth argument, load the
1612        // conversion info out of it and keep the copy as the summary salt, so the salt is the
1613        // caller's replay guard in both.
1614        let caller_chosen_salt = components.iter().find_map(|component| match component {
1615            AccountComponentInterface::AuthMultisig
1616            | AccountComponentInterface::AuthMultisigSmart
1617            | AccountComponentInterface::AuthGuardedMultisig => {
1618                Some(Self::CallerChosenSalt(component.name()))
1619            },
1620            _ => None,
1621        });
1622
1623        caller_chosen_salt.unwrap_or_else(|| {
1624            let name = components
1625                .iter()
1626                .find(|component| {
1627                    matches!(
1628                        component,
1629                        AccountComponentInterface::AuthNoAuth
1630                            | AccountComponentInterface::AuthNetworkAccount
1631                    )
1632                })
1633                .map_or_else(|| "unrecognized".into(), AccountComponentInterface::name);
1634
1635            Self::Ignored(name)
1636        })
1637    }
1638}
1639
1640/// Returns the conversion info an account should commit to settle its fee in the native asset at
1641/// rate 1/1, or `None` when the account pays its fee some other way.
1642///
1643/// Shared with note screening so the two cannot disagree about what an account needs.
1644pub(crate) fn native_fee_conversion_info(
1645    account_code_interface: &AccountCodeInterface,
1646    fee_parameters: &FeeParameters,
1647    protocol_config: &ProtocolConfig,
1648) -> Option<FeeConversionInfo> {
1649    if fee_parameters.verification_base_fee() == 0 {
1650        return None;
1651    }
1652
1653    // Only a fixed salt can be paired with this info by anyone other than the caller: where the
1654    // salt is the account's replay guard, the caller is the one who has to choose it.
1655    match FeeAuth::of(account_code_interface) {
1656        FeeAuth::FixedSalt => {
1657            Some(FeeConversionInfo::one_to_one(protocol_config.fee_asset_id().faucet_id()))
1658        },
1659        FeeAuth::CallerChosenSalt(_) | FeeAuth::Ignored(_) => None,
1660    }
1661}
1662
1663/// Verifies that the account can consume fee conversion info passed through the auth args.
1664///
1665/// Only the signature-based auth components read the auth args as conversion info (through
1666/// `miden::standards::fee`). On any other auth component the declared salt would go unread and no
1667/// conversion info would be committed, so the request is rejected here instead.
1668fn validate_fee_conversion_info_support(
1669    transaction_request: &TransactionRequest,
1670    account_code_interface: &AccountCodeInterface,
1671) -> Result<(), ClientError> {
1672    if transaction_request.fee_conversion_salt().is_none() {
1673        return Ok(());
1674    }
1675
1676    match FeeAuth::of(account_code_interface) {
1677        FeeAuth::FixedSalt | FeeAuth::CallerChosenSalt(_) => Ok(()),
1678        FeeAuth::Ignored(auth_component) => Err(ClientError::TransactionRequestError(
1679            TransactionRequestError::FeeConversionInfoUnsupported(auth_component),
1680        )),
1681    }
1682}
1683/// Verifies that every output note emitted directly by the transaction declares `account_id` as its
1684/// sender.
1685///
1686/// A note's sender is bound by the kernel to the account that emits it, and note scripts (e.g.
1687/// P2IDE reclaim) authorize on that field, so an output note declaring a foreign sender can never
1688/// be executed. Catching it here yields a clear, immediate error instead of a cryptic failure deep
1689/// in transaction script building.
1690fn validate_output_note_senders(
1691    transaction_request: &TransactionRequest,
1692    account_id: AccountId,
1693) -> Result<(), ClientError> {
1694    for note in transaction_request.expected_output_own_notes() {
1695        let sender = note.metadata().sender();
1696        if sender != account_id {
1697            return Err(ClientError::TransactionRequestError(
1698                TransactionRequestError::OutputNoteSenderMismatch {
1699                    expected: account_id,
1700                    actual: sender,
1701                },
1702            ));
1703        }
1704    }
1705
1706    Ok(())
1707}
1708
1709/// Ensures a transaction request is compatible with the account's committed vault assets, primarily
1710/// by checking asset balances against the requested transfers.
1711fn validate_basic_account_request(
1712    transaction_request: &TransactionRequest,
1713    vault_assets: &[Asset],
1714) -> Result<(), ClientError> {
1715    let (fungible_balance_map, non_fungible_set) = get_outgoing_assets(transaction_request);
1716    let (incoming_fungible_balance_map, incoming_non_fungible_balance_set) =
1717        transaction_request.incoming_assets();
1718
1719    // Aggregate the account's fungible balance per faucet in one pass. A faucet's fungible asset
1720    // may occupy more than one callback-flag vault key, so all matching entries are summed.
1721    let mut available_fungible: BTreeMap<AccountId, u64> = BTreeMap::new();
1722    for asset in vault_assets {
1723        if let Some(fungible) = asset.as_fungible() {
1724            let balance = available_fungible.entry(fungible.faucet_id()).or_default();
1725            *balance = balance.saturating_add(fungible.amount().as_u64());
1726        }
1727    }
1728
1729    // Check if the account balance plus incoming assets is greater than or equal to the outgoing
1730    // fungible assets
1731    for (faucet_id, amount) in fungible_balance_map {
1732        let account_asset_amount = available_fungible.get(&faucet_id).copied().unwrap_or(0);
1733        let incoming_balance = incoming_fungible_balance_map.get(&faucet_id).unwrap_or(&0);
1734        if account_asset_amount + incoming_balance < amount {
1735            return Err(ClientError::AssetError(AssetError::FungibleAssetAmountNotSufficient {
1736                minuend: account_asset_amount,
1737                subtrahend: amount,
1738            }));
1739        }
1740    }
1741
1742    // Check if the account balance plus incoming assets is greater than or equal to the outgoing
1743    // non fungible assets
1744    for non_fungible in &non_fungible_set {
1745        let held = vault_assets.iter().any(|asset| asset == non_fungible);
1746        if !held && !incoming_non_fungible_balance_set.contains(non_fungible) {
1747            return Err(ClientError::TransactionRequestError(
1748                TransactionRequestError::MissingNonFungibleAsset(non_fungible.faucet_id()),
1749            ));
1750        }
1751    }
1752
1753    Ok(())
1754}
1755
1756/// Fetches a foreign account's proof and details from the network, converts them into
1757/// [`AccountInputs`], and caches the returned code in the store for future requests.
1758///
1759/// Storage maps the node caps as oversized (returned truncated) are carried root-only in the
1760/// inputs; reads from them resolve lazily as per-key witnesses during execution.
1761///
1762/// # Errors
1763/// Fails if the account is private: the RPC does not return account details for them, causing
1764/// [`TransactionRequestError::ForeignAccountDataMissing`].
1765pub(crate) async fn fetch_public_account_inputs(
1766    store: &Arc<dyn Store>,
1767    rpc_api: &Arc<dyn NodeRpcClient>,
1768    account_id: AccountId,
1769    storage_requirements: AccountStorageRequirements,
1770    account_state_at: AccountStateAt,
1771) -> Result<AccountInputs, ClientError> {
1772    let known_code: Option<AccountCode> =
1773        store.get_foreign_account_code(vec![account_id]).await?.into_values().next();
1774
1775    // Tracked accounts skip the asset list when unchanged; untracked accounts fetch it in full so
1776    // asset reads need no execution-time RPC.
1777    let vault = store
1778        .get_account_header(account_id)
1779        .await?
1780        .map_or(VaultFetch::Always, |(header, ..)| {
1781            VaultFetch::IfChangedFrom(header.vault_root())
1782        });
1783
1784    let (_block_num, account_proof) = rpc_api
1785        .get_account(
1786            account_id,
1787            GetAccountRequest::new()
1788                .with_storage(StorageMapFetch::Slots(storage_requirements.clone()))
1789                .at(account_state_at)
1790                .with_known_code(known_code)
1791                .with_vault(vault),
1792        )
1793        .await?;
1794
1795    let account_inputs = request::account_proof_into_inputs(account_proof)?;
1796
1797    let _ = store
1798        .upsert_foreign_account_code(account_id, account_inputs.code().clone())
1799        .await
1800        .inspect_err(|err| {
1801            tracing::warn!(
1802                %account_id,
1803                %err,
1804                "Failed to persist foreign account code to store"
1805            );
1806        });
1807
1808    Ok(account_inputs)
1809}
1810
1811/// Promotes a submission failure whose outcome is unknown, attaching everything a retry needs. Any
1812/// other failure is a rejection the node issued deliberately and passes through unchanged.
1813fn promote_indeterminate_submission(
1814    err: RpcError,
1815    transaction: ProvenTransaction,
1816    transaction_inputs: TransactionInputs,
1817) -> ClientError {
1818    if !err.is_indeterminate_submission() {
1819        return ClientError::RpcError(err);
1820    }
1821
1822    ClientError::SubmissionOutcomeUnknown {
1823        transaction: Box::new(transaction),
1824        transaction_inputs: Box::new(transaction_inputs),
1825        source: err,
1826    }
1827}
1828
1829/// Extracts notes from [`RawOutputNotes`].
1830/// Used for:
1831/// - Checking the relevance of notes to save them as input notes.
1832/// - Validate hashes versus expected output notes after a transaction is executed.
1833pub fn notes_from_output(output_notes: &RawOutputNotes) -> impl Iterator<Item = &Note> {
1834    output_notes.iter().filter_map(|n| match n {
1835        RawOutputNote::Full(n) => Some(n),
1836        RawOutputNote::Partial(_) => None,
1837    })
1838}
1839
1840/// Validates that the executed transaction's output recipients match what was expected in the
1841/// transaction request.
1842pub(crate) fn validate_executed_transaction(
1843    executed_transaction: &ExecutedTransaction,
1844    expected_output_recipients: &[NoteRecipient],
1845) -> Result<(), ClientError> {
1846    let tx_output_recipient_digests = executed_transaction
1847        .output_notes()
1848        .iter()
1849        .filter_map(|n| n.recipient().map(NoteRecipient::digest))
1850        .collect::<Vec<_>>();
1851
1852    let missing_recipient_digest: Vec<Word> = expected_output_recipients
1853        .iter()
1854        .filter_map(|recipient| {
1855            (!tx_output_recipient_digests.contains(&recipient.digest()))
1856                .then_some(recipient.digest())
1857        })
1858        .collect();
1859
1860    if !missing_recipient_digest.is_empty() {
1861        return Err(ClientError::MissingOutputRecipients(missing_recipient_digest));
1862    }
1863
1864    Ok(())
1865}
1866
1867// TESTS
1868// ================================================================================================
1869
1870#[cfg(test)]
1871mod tests {
1872    use alloc::vec;
1873
1874    use miden_protocol::Word;
1875    use miden_protocol::account::auth::AuthSecretKey;
1876    use miden_protocol::account::{
1877        Account,
1878        AccountBuilder,
1879        AccountComponent,
1880        AccountComponentMetadata,
1881        AccountId,
1882        AccountType,
1883    };
1884    use miden_protocol::asset::{AssetId, FungibleAsset};
1885    use miden_protocol::block::{BlockHeader, BlockNumber, FeeParameters};
1886    use miden_protocol::crypto::rand::RandomCoin;
1887    use miden_protocol::note::{Note, NoteType};
1888    use miden_protocol::protocol_config::ProtocolConfig;
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_standards::account::AccountBuilderSchemaCommitmentExt;
1896    use miden_standards::account::auth::{
1897        Approver,
1898        ApproverSet,
1899        AuthGuardedMultisig,
1900        AuthGuardedMultisigConfig,
1901        AuthMultisig,
1902        AuthMultisigConfig,
1903        AuthMultisigSmart,
1904        AuthMultisigSmartConfig,
1905        AuthSingleSig,
1906        FeeConversionInfo,
1907        GuardianConfig,
1908        NoAuth,
1909        commit_fee_conversion_info,
1910    };
1911    use miden_standards::account::wallets::BasicWallet;
1912    use miden_standards::note::P2idNote;
1913
1914    use super::{
1915        AccountComponentInterface,
1916        NATIVE_FEE_CONVERSION_SALT,
1917        TransactionRequest,
1918        TransactionRequestBuilder,
1919        attach_native_fee_conversion_info,
1920        validate_fee_conversion_info_support,
1921        validate_output_note_senders,
1922    };
1923    use crate::ClientError;
1924    use crate::assembly::CodeBuilder;
1925    use crate::auth::AuthSchemeId;
1926    use crate::transaction::TransactionRequestError;
1927
1928    fn own_note_with_sender(sender: AccountId) -> Note {
1929        let faucet_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap();
1930        let target_id =
1931            AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();
1932        let mut rng = RandomCoin::new(Word::default());
1933
1934        P2idNote::builder()
1935            .sender(sender)
1936            .target(target_id)
1937            .asset(FungibleAsset::new(faucet_id, 100).unwrap())
1938            .note_type(NoteType::Public)
1939            .generate_serial_number(&mut rng)
1940            .build()
1941            .expect("note creation failed")
1942            .into()
1943    }
1944
1945    #[test]
1946    fn output_note_with_foreign_sender_is_rejected() {
1947        let account_id =
1948            AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();
1949        let foreign_sender = AccountId::try_from(ACCOUNT_ID_SENDER).unwrap();
1950        assert_ne!(account_id, foreign_sender);
1951
1952        let request = TransactionRequestBuilder::new()
1953            .own_output_notes(vec![own_note_with_sender(foreign_sender)])
1954            .build()
1955            .unwrap();
1956
1957        let err = validate_output_note_senders(&request, account_id).unwrap_err();
1958        match err {
1959            ClientError::TransactionRequestError(
1960                TransactionRequestError::OutputNoteSenderMismatch { expected, actual },
1961            ) => {
1962                assert_eq!(expected, account_id);
1963                assert_eq!(actual, foreign_sender);
1964            },
1965            other => panic!("expected OutputNoteSenderMismatch, got {other:?}"),
1966        }
1967    }
1968
1969    #[test]
1970    fn output_note_with_matching_sender_is_accepted() {
1971        let account_id =
1972            AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();
1973
1974        let request = TransactionRequestBuilder::new()
1975            .own_output_notes(vec![own_note_with_sender(account_id)])
1976            .build()
1977            .unwrap();
1978
1979        validate_output_note_senders(&request, account_id).unwrap();
1980    }
1981
1982    #[test]
1983    fn request_without_own_output_notes_is_accepted() {
1984        let account_id =
1985            AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();
1986        let faucet_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap();
1987
1988        // A consume-only request (input note, no own output notes) must pass the sender check.
1989        let request = TransactionRequestBuilder::new()
1990            .input_notes(vec![(own_note_with_sender(faucet_id), None)])
1991            .build()
1992            .unwrap();
1993
1994        validate_output_note_senders(&request, account_id).unwrap();
1995    }
1996
1997    /// Builds an account carrying `auth_component` and a basic wallet.
1998    fn account_with_auth(auth_component: impl Into<AccountComponent>) -> Account {
1999        AccountBuilder::new([7u8; 32])
2000            .account_type(AccountType::Public)
2001            .with_component(auth_component)
2002            .with_component(BasicWallet)
2003            .build_with_schema_commitment()
2004            .expect("account creation failed")
2005    }
2006
2007    fn fee_conversion_request() -> TransactionRequest {
2008        TransactionRequestBuilder::new()
2009            .fee_conversion_salt(Word::from([13u32, 14, 15, 16]))
2010            .build()
2011            .unwrap()
2012    }
2013
2014    #[test]
2015    fn fee_conversion_info_is_accepted_by_a_signature_authenticated_account() {
2016        let key = AuthSecretKey::new_falcon512_poseidon2();
2017        let auth = AuthSingleSig::new(Approver::new(
2018            key.public_key().to_commitment(),
2019            AuthSchemeId::Falcon512Poseidon2,
2020        ));
2021
2022        validate_fee_conversion_info_support(
2023            &fee_conversion_request(),
2024            &account_with_auth(auth).code_interface(),
2025        )
2026        .unwrap();
2027    }
2028
2029    #[test]
2030    fn fee_conversion_info_is_rejected_by_an_account_that_cannot_read_it() {
2031        let account = account_with_auth(NoAuth);
2032
2033        let err = validate_fee_conversion_info_support(
2034            &fee_conversion_request(),
2035            &account.code_interface(),
2036        )
2037        .expect_err("NoAuth does not read the auth args");
2038        match err {
2039            ClientError::TransactionRequestError(
2040                TransactionRequestError::FeeConversionInfoUnsupported(auth_component),
2041            ) => assert_eq!(auth_component, AccountComponentInterface::AuthNoAuth.name()),
2042            other => panic!("expected FeeConversionInfoUnsupported, got {other:?}"),
2043        }
2044    }
2045
2046    #[test]
2047    fn a_request_without_fee_conversion_info_skips_the_auth_component_check() {
2048        // `NoAuth` cannot read conversion info, but a request that declares none is unaffected.
2049        validate_fee_conversion_info_support(
2050            &TransactionRequestBuilder::new().build().unwrap(),
2051            &account_with_auth(NoAuth).code_interface(),
2052        )
2053        .unwrap();
2054    }
2055
2056    // NATIVE FEE CONVERSION INFO INJECTION
2057    // --------------------------------------------------------------------------------------------
2058
2059    /// Fee faucet the headers below name, distinct from the faucet [`fee_conversion_request`] pays
2060    /// in so the two can be told apart.
2061    const NATIVE_FEE_FAUCET: u128 = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET;
2062
2063    fn test_protocol_config() -> ProtocolConfig {
2064        ProtocolConfig::current(AssetId::new_fungible(NATIVE_FEE_FAUCET.try_into().unwrap()))
2065            .unwrap()
2066    }
2067
2068    /// Builds a block header with fees in the [`NATIVE_FEE_FAUCET`] asset.
2069    fn header_with_base_fee(verification_base_fee: u32) -> BlockHeader {
2070        let fee_parameters = FeeParameters::new(verification_base_fee);
2071        let (_, validator_keys) = miden_protocol::block::ValidatorConfig::random_with_signers(1);
2072
2073        BlockHeader::new(
2074            Word::empty(),
2075            BlockNumber::from(1u32),
2076            Word::empty(),
2077            Word::empty(),
2078            Word::empty(),
2079            Word::empty(),
2080            Word::empty(),
2081            validator_keys,
2082            fee_parameters,
2083            test_protocol_config().to_commitment(),
2084            None,
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            &test_protocol_config(),
2101        );
2102        *request.auth_arg()
2103    }
2104
2105    /// As [`injected_auth_arg`], but surfaces the attachment error instead of discarding it.
2106    fn try_injected_auth_arg(
2107        mut request: TransactionRequest,
2108        account: &Account,
2109        verification_base_fee: u32,
2110    ) -> Result<Option<Word>, ClientError> {
2111        attach_native_fee_conversion_info(
2112            &mut request,
2113            &account.code_interface(),
2114            &header_with_base_fee(verification_base_fee),
2115            &test_protocol_config(),
2116        )?;
2117        Ok(*request.auth_arg())
2118    }
2119
2120    fn singlesig_account() -> Account {
2121        let key = AuthSecretKey::new_falcon512_poseidon2();
2122        account_with_auth(AuthSingleSig::new(Approver::new(
2123            key.public_key().to_commitment(),
2124            AuthSchemeId::Falcon512Poseidon2,
2125        )))
2126    }
2127
2128    fn guarded_multisig_account() -> Account {
2129        let approvers = ApproverSet::new(
2130            vec![Approver::new(
2131                AuthSecretKey::new_falcon512_poseidon2().public_key().to_commitment(),
2132                AuthSchemeId::Falcon512Poseidon2,
2133            )],
2134            1,
2135        )
2136        .unwrap();
2137
2138        let guardian = GuardianConfig::new(Approver::new(
2139            AuthSecretKey::new_falcon512_poseidon2().public_key().to_commitment(),
2140            AuthSchemeId::Falcon512Poseidon2,
2141        ));
2142
2143        account_with_auth(
2144            AuthGuardedMultisig::new(AuthGuardedMultisigConfig::new(approvers, guardian).unwrap())
2145                .unwrap(),
2146        )
2147    }
2148
2149    #[test]
2150    fn native_fee_conversion_info_is_attached_on_a_fee_charging_chain() {
2151        let auth_arg = injected_auth_arg(
2152            TransactionRequestBuilder::new().build().unwrap(),
2153            &singlesig_account(),
2154            500,
2155        )
2156        .expect("a fee-charging chain should get conversion info attached");
2157
2158        let (expected, _) = commit_fee_conversion_info(
2159            FeeConversionInfo::one_to_one(AccountId::try_from(NATIVE_FEE_FAUCET).unwrap()),
2160            NATIVE_FEE_CONVERSION_SALT,
2161        );
2162        assert_eq!(auth_arg, expected, "the fee should be paid in the native asset at rate 1/1");
2163    }
2164
2165    #[test]
2166    fn an_explicit_auth_arg_is_not_overwritten() {
2167        let auth_arg = Word::from([21u32, 22, 23, 24]);
2168        let request = TransactionRequestBuilder::new().auth_arg(auth_arg).build().unwrap();
2169
2170        assert_eq!(
2171            injected_auth_arg(request, &singlesig_account(), 500),
2172            Some(auth_arg),
2173            "a request that declares its own auth arg keeps it"
2174        );
2175    }
2176
2177    /// Expected commitment for the native 1/1 conversion info under `salt`.
2178    fn native_commitment(salt: Word) -> Word {
2179        let (auth_arg, _) = commit_fee_conversion_info(
2180            FeeConversionInfo::one_to_one(AccountId::try_from(NATIVE_FEE_FAUCET).unwrap()),
2181            salt,
2182        );
2183        auth_arg
2184    }
2185
2186    #[test]
2187    fn a_declared_salt_is_used_for_the_native_commitment() {
2188        let salt = Word::from([17u32, 18, 19, 20]);
2189        let request = TransactionRequestBuilder::new().fee_conversion_salt(salt).build().unwrap();
2190
2191        let auth_arg = injected_auth_arg(request, &singlesig_account(), 500)
2192            .expect("a declared salt should still get native conversion info attached");
2193
2194        assert_eq!(auth_arg, native_commitment(salt));
2195    }
2196
2197    fn multisig_account() -> Account {
2198        let approvers = ApproverSet::new(
2199            vec![Approver::new(
2200                AuthSecretKey::new_falcon512_poseidon2().public_key().to_commitment(),
2201                AuthSchemeId::Falcon512Poseidon2,
2202            )],
2203            1,
2204        )
2205        .unwrap();
2206
2207        account_with_auth(AuthMultisig::new(AuthMultisigConfig::new(approvers)).unwrap())
2208    }
2209
2210    #[test]
2211    fn nothing_is_attached_where_it_is_not_needed_or_not_readable() {
2212        for (case, account, base_fee) in [
2213            ("a zero base fee charges nothing", singlesig_account(), 0),
2214            ("NoAuth never reads the auth args", account_with_auth(NoAuth), 500),
2215            ("a multisig salt is its own replay guard", multisig_account(), 500),
2216        ] {
2217            assert_eq!(
2218                injected_auth_arg(
2219                    TransactionRequestBuilder::new().build().unwrap(),
2220                    &account,
2221                    base_fee
2222                ),
2223                None,
2224                "{case}"
2225            );
2226        }
2227    }
2228
2229    // GUARDED MULTISIG
2230    // --------------------------------------------------------------------------------------------
2231
2232    /// `guarded_multisig.masm` loads the conversion info out of the auth args and pays the fee with
2233    /// it, so a declared asset and rate are what the account pays with rather than something
2234    /// discarded and reinterpreted as the summary salt.
2235    #[test]
2236    fn fee_conversion_info_is_accepted_by_a_guarded_multisig_account() {
2237        validate_fee_conversion_info_support(
2238            &fee_conversion_request(),
2239            &guarded_multisig_account().code_interface(),
2240        )
2241        .expect("a guarded multisig reads the auth args as conversion info");
2242    }
2243
2244    /// `guarded_multisig.masm` reuses the auth args as the summary salt after loading the
2245    /// conversion info out of them, exactly as `multisig.masm` does, so the same reasoning applies:
2246    /// the salt is the caller's replay guard and the client cannot pick it.
2247    #[test]
2248    fn a_guarded_multisig_account_must_declare_its_own_fee_conversion_info() {
2249        let err = try_injected_auth_arg(
2250            TransactionRequestBuilder::new().build().unwrap(),
2251            &guarded_multisig_account(),
2252            500,
2253        )
2254        .expect_err("a guarded multisig account cannot inherit the fixed native salt");
2255        match err {
2256            ClientError::TransactionRequestError(
2257                TransactionRequestError::FeeConversionInfoRequired(auth_component),
2258            ) => {
2259                assert_eq!(auth_component, AccountComponentInterface::AuthGuardedMultisig.name());
2260            },
2261            other => panic!("expected FeeConversionInfoRequired, got {other:?}"),
2262        }
2263
2264        let salt = Word::from([13u32, 14, 15, 16]);
2265        assert_eq!(
2266            try_injected_auth_arg(fee_conversion_request(), &guarded_multisig_account(), 500)
2267                .expect("a declared salt is accepted"),
2268            Some(native_commitment(salt)),
2269            "a guarded multisig account that declares a salt commits the native conversion info"
2270        );
2271
2272        assert_eq!(
2273            try_injected_auth_arg(
2274                TransactionRequestBuilder::new().build().unwrap(),
2275                &guarded_multisig_account(),
2276                0
2277            )
2278            .expect("a chain charging nothing needs no conversion info"),
2279            None,
2280        );
2281    }
2282
2283    // SMART MULTISIG
2284    // --------------------------------------------------------------------------------------------
2285
2286    fn smart_multisig_account() -> Account {
2287        let approvers = ApproverSet::new(
2288            vec![Approver::new(
2289                AuthSecretKey::new_falcon512_poseidon2().public_key().to_commitment(),
2290                AuthSchemeId::Falcon512Poseidon2,
2291            )],
2292            1,
2293        )
2294        .unwrap();
2295
2296        account_with_auth(AuthMultisigSmart::new(AuthMultisigSmartConfig::new(approvers)).unwrap())
2297    }
2298
2299    /// As of `0.16.0-rc.9` `multisig_smart.masm` loads the conversion info out of the auth args and
2300    /// pays the fee with it, exactly as `guarded_multisig.masm` does, so a declared asset and rate
2301    /// are what the account pays with rather than something discarded and reinterpreted as the
2302    /// summary salt.
2303    #[test]
2304    fn fee_conversion_info_is_accepted_by_a_smart_multisig_account() {
2305        validate_fee_conversion_info_support(
2306            &fee_conversion_request(),
2307            &smart_multisig_account().code_interface(),
2308        )
2309        .expect("a smart multisig reads the auth args as conversion info");
2310    }
2311
2312    /// `multisig_smart.masm` reuses the auth args as the summary salt after loading the conversion
2313    /// info out of them, so the same reasoning as for the other multisig flavours applies: the salt
2314    /// is the caller's replay guard and the client cannot pick it.
2315    #[test]
2316    fn a_smart_multisig_account_must_declare_its_own_fee_conversion_info() {
2317        let err = try_injected_auth_arg(
2318            TransactionRequestBuilder::new().build().unwrap(),
2319            &smart_multisig_account(),
2320            500,
2321        )
2322        .expect_err("a smart multisig account cannot inherit the fixed native salt");
2323        match err {
2324            ClientError::TransactionRequestError(
2325                TransactionRequestError::FeeConversionInfoRequired(auth_component),
2326            ) => {
2327                assert_eq!(auth_component, AccountComponentInterface::AuthMultisigSmart.name());
2328            },
2329            other => panic!("expected FeeConversionInfoRequired, got {other:?}"),
2330        }
2331
2332        let salt = Word::from([13u32, 14, 15, 16]);
2333        assert_eq!(
2334            try_injected_auth_arg(fee_conversion_request(), &smart_multisig_account(), 500)
2335                .expect("a declared salt is accepted"),
2336            Some(native_commitment(salt)),
2337            "a smart multisig account that declares a salt commits the native conversion info"
2338        );
2339
2340        assert_eq!(
2341            try_injected_auth_arg(
2342                TransactionRequestBuilder::new().build().unwrap(),
2343                &smart_multisig_account(),
2344                0
2345            )
2346            .expect("a chain charging nothing needs no conversion info"),
2347            None,
2348        );
2349    }
2350
2351    /// An account carrying a custom auth component names no recognized one, which
2352    /// `AccountInterface::new` asserts on rather than reports.
2353    #[test]
2354    fn an_unrecognized_auth_component_is_rejected_rather_than_panicking() {
2355        const CUSTOM_AUTH: &str = "
2356            use miden::protocol::native_account
2357
2358            @auth_script
2359            pub proc auth_custom
2360                exec.native_account::incr_nonce
2361                drop
2362            end
2363        ";
2364
2365        let code = CodeBuilder::default()
2366            .compile_component_code("miden::testing::custom_auth", CUSTOM_AUTH)
2367            .expect("custom auth component code should compile");
2368        let auth = AccountComponent::new(
2369            code,
2370            vec![],
2371            AccountComponentMetadata::new("miden::testing::custom_auth"),
2372        )
2373        .expect("custom auth component");
2374
2375        let account = account_with_auth(auth);
2376
2377        let err = validate_fee_conversion_info_support(
2378            &fee_conversion_request(),
2379            &account.code_interface(),
2380        )
2381        .expect_err("an account with no recognized auth component cannot read conversion info");
2382        assert!(matches!(
2383            err,
2384            ClientError::TransactionRequestError(
2385                TransactionRequestError::FeeConversionInfoUnsupported(_)
2386            )
2387        ));
2388
2389        assert_eq!(
2390            try_injected_auth_arg(TransactionRequestBuilder::new().build().unwrap(), &account, 500)
2391                .expect("a request declaring nothing is left alone"),
2392            None,
2393            "an auth component nothing can reason about gets nothing attached"
2394        );
2395    }
2396}