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