Skip to main content

miden_client/transaction/
mod.rs

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