Skip to main content

miden_client/transaction/
mod.rs

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