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