Skip to main content

miden_client/transaction/
mod.rs

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