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::sync::Arc;
68use alloc::vec::Vec;
69
70use miden_protocol::account::{Account, AccountCode, AccountCodeInterface, AccountId};
71use miden_protocol::asset::{Asset, NonFungibleAsset};
72use miden_protocol::block::{BlockHeader, BlockNumber};
73use miden_protocol::errors::AssetError;
74use miden_protocol::note::{
75    Note,
76    NoteAttachments,
77    NoteDetails,
78    NoteId,
79    NoteRecipient,
80    NoteScript,
81    NoteTag,
82};
83use miden_protocol::transaction::{AccountInputs, PartialBlockchain};
84use miden_protocol::vm::MIN_STACK_DEPTH;
85use miden_protocol::{Felt, Word};
86use miden_standards::account::faucets::FungibleFaucet;
87use miden_standards::account::interface::AccountInterfaceExt;
88use miden_tx::{DataStore, NoteConsumptionChecker, TransactionExecutor};
89use tracing::info;
90
91use super::Client;
92use crate::ClientError;
93use crate::note::{NoteScreenerError, NoteUpdateTracker, StandardNote};
94use crate::rpc::domain::account::{
95    AccountStorageRequirements,
96    GetAccountRequest,
97    StorageMapFetch,
98    VaultFetch,
99};
100use crate::rpc::encryption::{TransactionEncryptionKey, seal_transaction_inputs};
101use crate::rpc::{AccountStateAt, NodeRpcClient, RpcError};
102use crate::store::data_store::{ClientDataStore, build_partial_mmr_with_paths};
103use crate::store::input_note_states::ExpectedNoteState;
104use crate::store::{
105    AccountRecord,
106    InputNoteRecord,
107    InputNoteState,
108    NoteFilter,
109    NoteRecordError,
110    OutputNoteRecord,
111    Store,
112    StoreError,
113    TransactionFilter,
114};
115use crate::sync::NoteTagRecord;
116use crate::transaction::batch::InMemoryBatchDataStore;
117
118pub mod batch;
119pub use batch::{BatchBuilder, BatchBuilderError};
120
121mod chain_anchor;
122pub use chain_anchor::{ChainAnchor, ChainAnchorError};
123
124#[cfg(feature = "dap")]
125mod dap_executor;
126mod prover;
127pub use prover::TransactionProver;
128
129mod record;
130pub use record::{
131    DiscardCause,
132    TransactionDetails,
133    TransactionRecord,
134    TransactionStatus,
135    TransactionStatusVariant,
136};
137
138mod store_update;
139pub use store_update::TransactionStoreUpdate;
140
141mod request;
142pub use request::{
143    ForeignAccount,
144    NoteArgs,
145    PaymentNoteDescription,
146    PswapTransactionData,
147    SwapTransactionData,
148    TransactionRequest,
149    TransactionRequestBuilder,
150    TransactionRequestError,
151    TransactionScriptTemplate,
152};
153
154mod observer;
155pub use observer::TransactionObserver;
156
157mod result;
158// RE-EXPORTS
159// ================================================================================================
160pub use miden_protocol::transaction::{
161    ExecutedTransaction,
162    InputNote,
163    InputNotes,
164    OutputNote,
165    OutputNotes,
166    ProvenTransaction,
167    PublicOutputNote,
168    RawOutputNote,
169    RawOutputNotes,
170    TransactionArgs,
171    TransactionId,
172    TransactionInputs,
173    TransactionKernel,
174    TransactionScript,
175    TransactionScriptRoot,
176    TransactionSummary,
177};
178pub use miden_protocol::vm::{AdviceInputs, AdviceMap};
179pub use miden_standards::account::interface::{AccountComponentInterface, AccountInterface};
180pub use miden_standards::tx_script::{
181    ExpirationTransactionScript,
182    SendNotesTransactionScriptError,
183};
184pub use miden_tx::auth::TransactionAuthenticator;
185pub use miden_tx::{
186    DataStoreError,
187    LocalTransactionProver,
188    ProvingOptions,
189    TransactionExecutorError,
190    TransactionProverError,
191};
192pub use result::TransactionResult;
193
194/// Transaction management methods
195impl<AUTH> Client<AUTH>
196where
197    AUTH: TransactionAuthenticator + Sync + 'static,
198{
199    // TRANSACTION DATA RETRIEVAL
200    // --------------------------------------------------------------------------------------------
201
202    /// Retrieves tracked transactions, filtered by [`TransactionFilter`].
203    pub async fn get_transactions(
204        &self,
205        filter: TransactionFilter,
206    ) -> Result<Vec<TransactionRecord>, ClientError> {
207        self.store.get_transactions(filter).await.map_err(Into::into)
208    }
209
210    // TRANSACTION BATCH
211    // --------------------------------------------------------------------------------------------
212
213    /// Open a new [`BatchBuilder`] for accumulating transactions across one or more local
214    /// accounts.
215    ///
216    /// See [`crate::transaction::batch`] for usage and constraints.
217    pub fn new_transaction_batch(&mut self) -> BatchBuilder<'_, AUTH> {
218        let inner_data_store = ClientDataStore::new(self.store.clone(), self.rpc_api.clone());
219        BatchBuilder {
220            client: self,
221            data_store: InMemoryBatchDataStore::new(inner_data_store),
222            pushed_txs: Vec::new(),
223            consumed_input_notes: BTreeSet::new(),
224        }
225    }
226
227    // TRANSACTION
228    // --------------------------------------------------------------------------------------------
229
230    /// Executes a transaction specified by the request against the specified account,
231    /// proves it, submits it to the network, and updates the local database.
232    ///
233    /// Uses the client's default prover (configured via
234    /// [`crate::builder::ClientBuilder::prover`]).
235    pub async fn submit_new_transaction(
236        &mut self,
237        account_id: AccountId,
238        transaction_request: TransactionRequest,
239    ) -> Result<TransactionId, ClientError> {
240        let prover = self.tx_prover.clone();
241        self.submit_new_transaction_with_prover(account_id, transaction_request, prover)
242            .await
243    }
244
245    /// Executes a transaction specified by the request against the specified account,
246    /// proves it with the provided prover, submits it to the network, and updates the local
247    /// database.
248    ///
249    /// This is useful for falling back to a different prover (e.g., local) when the default
250    /// prover (e.g., remote) fails with a [`ClientError::TransactionProvingError`].
251    pub async fn submit_new_transaction_with_prover(
252        &mut self,
253        account_id: AccountId,
254        transaction_request: TransactionRequest,
255        tx_prover: Arc<dyn TransactionProver>,
256    ) -> Result<TransactionId, ClientError> {
257        // Register any missing NTX scripts before the main transaction.
258        // The registration path contains its own full execute -> prove -> submit pipeline.
259        if !transaction_request.expected_ntx_scripts().is_empty() {
260            Box::pin(self.ensure_ntx_scripts_registered(
261                account_id,
262                transaction_request.expected_ntx_scripts(),
263                tx_prover.clone(),
264            ))
265            .await?;
266        }
267
268        let tx_result = self.execute_transaction(account_id, transaction_request).await?;
269        let tx_id = tx_result.executed_transaction().id();
270
271        let proven_transaction = self.prove_transaction_with(&tx_result, tx_prover).await?;
272        let submission_height =
273            self.submit_proven_transaction(proven_transaction, &tx_result).await?;
274
275        // The transaction has been accepted by the node; the local store update
276        // is a separate step that can fail independently. On failure, return a
277        // distinct error carrying the pending update so the caller can decide
278        // how to recover (re-apply later via `apply_transaction_update`,
279        // persist for the next session, etc.).
280        //
281        // The update is boxed so it does not inflate the enclosing future
282        // across await points (triggers clippy::large_futures).
283        let tx_update =
284            Box::new(self.get_transaction_store_update(&tx_result, submission_height).await?);
285
286        if let Err(apply_err) = self.apply_transaction_update((*tx_update).clone()).await {
287            info!(
288                "apply_transaction_update failed for submitted tx {tx_id}; returning \
289                 ApplyTransactionAfterSubmitFailed with the pending update attached: {apply_err}"
290            );
291            return Err(ClientError::ApplyTransactionAfterSubmitFailed {
292                pending_update: tx_update,
293                source: Box::new(apply_err),
294            });
295        }
296
297        // Fire transaction observers (mirrors `apply_transaction`). Per-observer failures are
298        // logged and never propagate — they're feature-specific side-channels, not part of the
299        // submit contract.
300        for observer in &self.transaction_observers {
301            crate::errors::log_observer_failure(
302                observer.name(),
303                "TransactionObserver::apply",
304                observer.apply(&tx_result).await,
305            );
306        }
307
308        Ok(tx_id)
309    }
310
311    /// Creates and executes a transaction specified by the request against the specified account,
312    /// but doesn't change the local database.
313    ///
314    /// # Errors
315    ///
316    /// - Returns [`ClientError::MissingOutputRecipients`] if the [`TransactionRequest`] output
317    ///   notes are not a subset of executor's output notes.
318    /// - Returns a [`ClientError::TransactionExecutorError`] if the execution fails.
319    /// - Returns a [`ClientError::TransactionRequestError`] if the request is invalid.
320    pub async fn execute_transaction(
321        &mut self,
322        account_id: AccountId,
323        transaction_request: TransactionRequest,
324    ) -> Result<TransactionResult, ClientError> {
325        self.execute_transaction_with_mode(
326            account_id,
327            transaction_request,
328            TransactionExecutionMode::Standard,
329            None,
330        )
331        .await
332    }
333
334    /// Creates and executes a transaction specified by the request against the specified account,
335    /// using the provided [`ChainAnchor`] as the reference block instead of the current sync
336    /// height. Like [`Self::execute_transaction`], it doesn't change the local database.
337    ///
338    /// Since protocol 0.16 the signed transaction summary binds the reference block commitment,
339    /// so signatures collected over a summary only authorize an execution whose reference block
340    /// is the one the summary was built at. This method makes such an execution reproducible on
341    /// any client, regardless of its sync height: the anchor supplies the reference block header
342    /// and a consistent [`PartialBlockchain`], typically captured by the transaction's original
343    /// proposer via [`Self::chain_anchor_for_request`] and shipped alongside the signed data.
344    ///
345    /// Callers holding an anchor from an untrusted source should first compare
346    /// [`ChainAnchor::block_commitment`] against an independently trusted value (e.g. the block
347    /// commitment bound into the signed transaction summary).
348    ///
349    /// Foreign account proofs are fetched at the anchor's block, so requests with foreign
350    /// accounts additionally require the node to serve account state at that block.
351    ///
352    /// # Errors
353    ///
354    /// In addition to the [`Self::execute_transaction`] errors:
355    /// - Returns [`ClientError::ChainAnchorError`] if an authenticated input note's creation block
356    ///   is not tracked by the anchor.
357    /// - Returns a [`ClientError::TransactionExecutorError`] if an input note was created after the
358    ///   anchored reference block.
359    /// - Returns [`ChainAnchorError::AnchoredTransactionExpired`] if the executed transaction's
360    ///   expiration block has already been reached, which the network would reject.
361    pub async fn execute_transaction_at(
362        &mut self,
363        account_id: AccountId,
364        transaction_request: TransactionRequest,
365        anchor: ChainAnchor,
366    ) -> Result<TransactionResult, ClientError> {
367        let result = self
368            .execute_transaction_with_mode(
369                account_id,
370                transaction_request,
371                TransactionExecutionMode::Standard,
372                Some(Box::new(anchor)),
373            )
374            .await?;
375
376        // The expiration delta counts from the anchored reference block, so a stale anchor can
377        // yield an already-expired transaction, which the network would only reject after the
378        // caller has paid for proving. The sync height never runs ahead of the real tip, so this
379        // fires only on transactions that are certainly too late.
380        let expiration = result.executed_transaction().expiration_block_num();
381        let sync_height = self.store.get_sync_height().await?;
382        if expiration <= sync_height {
383            return Err(
384                ChainAnchorError::AnchoredTransactionExpired { expiration, sync_height }.into()
385            );
386        }
387
388        Ok(result)
389    }
390
391    /// Captures a [`ChainAnchor`] at the client's current sync height, tracking the blocks in
392    /// `tracked_blocks` (in addition to the reference block itself, which needs no tracking) so
393    /// that transactions consuming authenticated notes created in those blocks can later execute
394    /// against the anchor.
395    async fn chain_anchor_at_tip(
396        &self,
397        tracked_blocks: BTreeSet<BlockNumber>,
398    ) -> Result<ChainAnchor, ClientError> {
399        let sync_height = self.store.get_sync_height().await?;
400
401        let (header, _had_notes) = self
402            .store
403            .get_block_header_by_num(sync_height)
404            .await?
405            .ok_or(StoreError::BlockHeaderNotFound(sync_height))?;
406
407        let mut tracked_blocks = tracked_blocks;
408        // The kernel extends the MMR with the reference block itself, so it needs no path.
409        tracked_blocks.remove(&sync_height);
410
411        let block_headers: Vec<BlockHeader> = self
412            .store
413            .get_block_headers(&tracked_blocks)
414            .await?
415            .into_iter()
416            .map(|(header, _has_notes)| header)
417            .collect();
418
419        // `Store::get_block_headers` may silently omit missing headers, so verify each requested
420        // block is present rather than comparing lengths.
421        let fetched_nums: BTreeSet<BlockNumber> =
422            block_headers.iter().map(BlockHeader::block_num).collect();
423        if let Some(&missing) = tracked_blocks.difference(&fetched_nums).next() {
424            return Err(StoreError::BlockHeaderNotFound(missing).into());
425        }
426
427        let peaks = self.store.get_current_blockchain_peaks().await?;
428        let partial_mmr = build_partial_mmr_with_paths(&self.store, peaks, &block_headers).await?;
429
430        let chain = PartialBlockchain::new(partial_mmr, block_headers)?;
431
432        Ok(ChainAnchor::new(header, chain)?)
433    }
434
435    /// Captures a [`ChainAnchor`] at the client's current sync height, tracking the creation
436    /// blocks of the request's authenticated input notes so that the request can later execute
437    /// against the anchor.
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 input_note_ids: Vec<NoteId> = transaction_request.input_note_ids().collect();
457
458        let tracked_blocks: BTreeSet<BlockNumber> = if input_note_ids.is_empty() {
459            BTreeSet::new()
460        } else {
461            self.store
462                .get_input_notes(NoteFilter::List(input_note_ids))
463                .await?
464                .iter()
465                .filter(|record| record.is_authenticated())
466                .filter_map(|record| record.inclusion_proof())
467                .map(|proof| proof.location().block_num())
468                .collect()
469        };
470
471        self.chain_anchor_at_tip(tracked_blocks).await
472    }
473
474    /// Executes `transaction_request` (e.g. consuming a note) through the DAP program executor,
475    /// so a DAP client can attach and step through the whole transaction — kernel, note scripts,
476    /// and account code — instead of only a standalone transaction script.
477    ///
478    /// This is a debugging entry point: it runs the transaction interactively under the debug
479    /// adapter and does not prove, submit, or apply the result. The listen address (and optional
480    /// replay-snapshot path) are taken from the globally installed
481    /// [`DapConfig`](miden_debug::DapConfig).
482    ///
483    /// # Errors
484    ///
485    /// This applies the same request preparation and output-recipient validation as
486    /// [`Self::execute_transaction`], and returns the corresponding [`ClientError`] on failure.
487    #[cfg(feature = "dap")]
488    pub async fn execute_transaction_with_dap(
489        &mut self,
490        account_id: AccountId,
491        transaction_request: TransactionRequest,
492    ) -> Result<TransactionResult, ClientError> {
493        self.execute_transaction_with_mode(
494            account_id,
495            transaction_request,
496            TransactionExecutionMode::Dap,
497            None,
498        )
499        .await
500    }
501
502    /// Executes a prepared transaction with the selected program executor while keeping request
503    /// preparation, data-store population, note filtering, and result validation identical across
504    /// execution modes.
505    async fn execute_transaction_with_mode(
506        &mut self,
507        account_id: AccountId,
508        transaction_request: TransactionRequest,
509        execution_mode: TransactionExecutionMode,
510        anchor: Option<Box<ChainAnchor>>,
511    ) -> Result<TransactionResult, ClientError> {
512        let account: Account = self.get_native_account_record(account_id).await?.try_into()?;
513
514        validate_account_request(&transaction_request, &account)?;
515        let prep = self
516            .prepare_transaction(account.code_interface(), transaction_request, anchor.as_deref())
517            .await?;
518
519        let mut data_store = ClientDataStore::new(self.store.clone(), self.rpc_api.clone());
520        if let Some(anchor) = anchor {
521            data_store = data_store.with_chain_anchor(*anchor);
522        }
523        data_store.register_note_scripts(prep.output_note_scripts());
524        for fpi_account in &prep.foreign_account_inputs {
525            data_store.mast_store().load_account_code(fpi_account.code());
526        }
527        data_store.register_foreign_account_inputs(prep.foreign_account_inputs);
528
529        data_store.mast_store().load_account_code(account.code());
530
531        let mut notes = prep.notes;
532        if prep.ignore_invalid_notes {
533            notes = self
534                .get_valid_input_notes(
535                    &data_store,
536                    account.id(),
537                    prep.block_num,
538                    notes,
539                    prep.tx_args.clone(),
540                )
541                .await?;
542        }
543
544        let executed_transaction = match execution_mode {
545            TransactionExecutionMode::Standard => {
546                self.build_executor(&data_store)?
547                    .execute_transaction(account_id, prep.block_num, notes, prep.tx_args)
548                    .await?
549            },
550            #[cfg(feature = "dap")]
551            TransactionExecutionMode::Dap => {
552                self.build_dap_executor(&data_store)?
553                    .execute_transaction(account_id, prep.block_num, notes, prep.tx_args)
554                    .await?
555            },
556        };
557
558        validate_executed_transaction(&executed_transaction, &prep.output_recipients)?;
559        TransactionResult::new(executed_transaction, prep.future_notes)
560    }
561
562    /// Performs the data-store-independent setup shared by `execute_transaction` and
563    /// `execute_transaction_for_batch`: loads/filters input notes, builds the transaction script
564    /// and args, retrieves foreign-account inputs, and computes the reference block number.
565    ///
566    /// This method does not write to the store: any state produced by the transaction is
567    /// persisted only after the transaction executes successfully.
568    ///
569    /// Checking the request against the account's balances is the caller's job, since it needs a
570    /// full [`Account`] (see [`validate_account_request`]). Batch execution only has the in-batch
571    /// [`miden_protocol::account::PartialAccount`] and so skips it; the executor still rejects an
572    /// unsatisfiable request.
573    ///
574    /// When `anchor` is provided, the reference block is the anchor's block instead of the
575    /// current sync height, and the recency check is skipped — anchored execution deliberately
576    /// references a block older than the tip.
577    pub(crate) async fn prepare_transaction(
578        &self,
579        account_code_interface: AccountCodeInterface,
580        transaction_request: TransactionRequest,
581        anchor: Option<&ChainAnchor>,
582    ) -> Result<PreparedTransaction, ClientError> {
583        if anchor.is_none() {
584            self.validate_recency().await?;
585        }
586
587        // Retrieve all input notes from the store.
588        let mut stored_note_records = self
589            .store
590            .get_input_notes(NoteFilter::List(transaction_request.input_note_ids().collect()))
591            .await?;
592
593        // Verify that none of the authenticated input notes are already consumed.
594        for note in &stored_note_records {
595            if note.is_consumed() {
596                let id = note.id().expect(
597                    "stored note records reaching this check carry metadata so id() is Some",
598                );
599                return Err(ClientError::TransactionRequestError(
600                    TransactionRequestError::InputNoteAlreadyConsumed(id),
601                ));
602            }
603        }
604
605        // Only keep authenticated input notes from the store.
606        stored_note_records.retain(InputNoteRecord::is_authenticated);
607
608        let notes = transaction_request.build_input_notes(stored_note_records)?;
609
610        // Each authenticated note's creation block must be tracked by the anchor; fail with a
611        // typed error so callers can recapture a wider anchor. Notes newer than the anchor are
612        // left for the executor to reject.
613        if let Some(anchor) = anchor {
614            for note in notes.iter() {
615                if let Some(location) = note.location() {
616                    let block_num = location.block_num();
617                    if block_num < anchor.block_num()
618                        && !anchor.partial_blockchain().contains_block(block_num)
619                    {
620                        return Err(ChainAnchorError::BlockNotTracked { block_num }.into());
621                    }
622                }
623            }
624        }
625
626        let output_recipients =
627            transaction_request.expected_output_recipients().cloned().collect::<Vec<_>>();
628
629        let future_notes: Vec<(NoteDetails, NoteTag)> =
630            transaction_request.expected_future_notes().cloned().collect();
631
632        let tx_script = transaction_request.build_transaction_script(&account_code_interface)?;
633
634        let foreign_accounts = transaction_request.foreign_accounts().clone();
635
636        // The reference block: the anchor's block when pinned, the sync height otherwise.
637        // Foreign account proofs are fetched at this block to stay consistent with it.
638        let block_num = match anchor {
639            Some(anchor) => anchor.block_num(),
640            None => self.store.get_sync_height().await?,
641        };
642
643        let foreign_account_inputs =
644            self.retrieve_foreign_account_inputs(foreign_accounts, block_num).await?;
645
646        let ignore_invalid_notes = transaction_request.ignore_invalid_input_notes();
647
648        let tx_args = transaction_request.into_transaction_args(tx_script);
649
650        Ok(PreparedTransaction {
651            notes,
652            output_recipients,
653            future_notes,
654            tx_args,
655            foreign_account_inputs,
656            block_num,
657            ignore_invalid_notes,
658        })
659    }
660
661    /// Proves the specified transaction using the prover configured for this client.
662    pub async fn prove_transaction(
663        &self,
664        tx_result: &TransactionResult,
665    ) -> Result<ProvenTransaction, ClientError> {
666        self.prove_transaction_with(tx_result, self.tx_prover.clone()).await
667    }
668
669    /// Proves the specified transaction using the provided prover.
670    ///
671    /// # Errors
672    ///
673    /// - Returns a [`ClientError::TransactionProvingError`] if the prover fails to produce a proof.
674    /// - Returns a [`ClientError::MismatchedProvenTransaction`] if the prover returns a proof of a
675    ///   transaction other than the requested one.
676    pub async fn prove_transaction_with(
677        &self,
678        tx_result: &TransactionResult,
679        tx_prover: Arc<dyn TransactionProver>,
680    ) -> Result<ProvenTransaction, ClientError> {
681        info!("Proving transaction...");
682
683        let executed_transaction = tx_result.executed_transaction();
684        let proven_transaction = tx_prover.prove(executed_transaction.clone().into()).await?;
685
686        // A prover is trusted with the witness, but not with choosing which transaction gets
687        // submitted. Everything downstream (submission, the local store update, the returned
688        // id) is derived from `tx_result`, so a proof of anything else would be submitted
689        // while the local state recorded the transaction that never reached the network.
690        //
691        // The id commits to the initial and final account commitments and to the input and
692        // output note commitments; the account commitments in turn commit to the account id,
693        // so a matching id covers the account as well.
694        if proven_transaction.id() != executed_transaction.id() {
695            return Err(ClientError::MismatchedProvenTransaction {
696                requested: executed_transaction.id(),
697                returned: proven_transaction.id(),
698            });
699        }
700
701        info!("Transaction proven.");
702
703        Ok(proven_transaction)
704    }
705
706    /// Submits a previously proven transaction to the RPC endpoint and returns the node’s chain tip
707    /// upon mempool admission.
708    pub async fn submit_proven_transaction(
709        &mut self,
710        proven_transaction: ProvenTransaction,
711        transaction_inputs: impl Into<TransactionInputs>,
712    ) -> Result<BlockNumber, ClientError> {
713        info!("Submitting transaction to the network...");
714        let tx_id = proven_transaction.id();
715        let key = self.transaction_encryption_key().await?;
716        let sealed_inputs =
717            seal_transaction_inputs(&mut self.rng, &key, tx_id, &transaction_inputs.into())?;
718        let result =
719            self.rpc_api.submit_proven_transaction(proven_transaction, sealed_inputs).await;
720        if let Err(err) = &result {
721            self.forget_stale_transaction_encryption_key(err).await;
722        }
723        let block_num = result?;
724        info!("Transaction submitted.");
725
726        Ok(block_num)
727    }
728
729    /// Returns the validator set's transaction encryption key, fetching and verifying it on first
730    /// use.
731    ///
732    /// The key is public data shared by the whole validator set, so it is cached in the store and
733    /// reused across submissions and restarts. A freshly fetched key is verified against the
734    /// validator set committed in the chain tip before it is cached or used: the endpoint is served
735    /// by the RPC operator, which is the party the encryption keeps out.
736    pub(crate) async fn transaction_encryption_key(
737        &self,
738    ) -> Result<TransactionEncryptionKey, ClientError> {
739        if let Some(key) = self.store.get_transaction_encryption_key().await? {
740            return Ok(key);
741        }
742
743        let attested = self.rpc_api.get_transaction_encryption_key().await?;
744
745        // The genesis commitment scopes the attestation to this chain, and the chain tip carries
746        // the validator set currently entitled to attest. Both come from the local store, so a
747        // response cannot supply its own trust anchor.
748        let genesis_commitment =
749            self.trusted_block_header(BlockNumber::GENESIS).await?.commitment();
750        let chain_tip = self.store.get_sync_height().await?;
751        let validator_keys = self.trusted_block_header(chain_tip).await?.validator_keys().clone();
752
753        let key = attested.verify(genesis_commitment, &validator_keys)?;
754        self.store.set_transaction_encryption_key(&key).await?;
755
756        Ok(key)
757    }
758
759    /// Installs the transaction encryption key that submission seals against, skipping the fetch
760    /// and its attestation check.
761    #[cfg(feature = "testing")]
762    pub async fn seed_transaction_encryption_key(
763        &self,
764        key: TransactionEncryptionKey,
765    ) -> Result<(), ClientError> {
766        Ok(self.store.set_transaction_encryption_key(&key).await?)
767    }
768
769    /// Evicts the cached encryption key when a submission was rejected for having been sealed
770    /// against a key the validator does not hold, so the next submission fetches a fresh one.
771    ///
772    /// An eviction failure is logged rather than returned: the caller is already reporting the
773    /// submission error, which the store error must not mask.
774    pub(crate) async fn forget_stale_transaction_encryption_key(&self, err: &RpcError) {
775        if err.is_stale_transaction_encryption_key()
776            && let Err(err) = self.store.remove_transaction_encryption_key().await
777        {
778            tracing::warn!("failed to evict the stale transaction encryption key: {err}");
779        }
780    }
781
782    /// Returns a locally stored block header, which the client has already authenticated during
783    /// sync.
784    ///
785    /// # Errors
786    /// Returns an error if the header is not stored locally, which means the client has not synced
787    /// far enough to have a trust anchor.
788    async fn trusted_block_header(
789        &self,
790        block_num: BlockNumber,
791    ) -> Result<BlockHeader, ClientError> {
792        self.store.get_block_header_by_num(block_num).await?.map(|(header, _)| header).ok_or_else(
793            || {
794                ClientError::ChainValidationError(alloc::format!(
795                    "block header {block_num} is not tracked locally; sync the client before it can verify data against the chain"
796                ))
797            },
798        )
799    }
800
801    /// Builds a [`TransactionStoreUpdate`] for the provided transaction result at the specified
802    /// submission height.
803    pub async fn get_transaction_store_update(
804        &self,
805        tx_result: &TransactionResult,
806        submission_height: BlockNumber,
807    ) -> Result<TransactionStoreUpdate, TransactionStoreUpdateError> {
808        let note_updates = self.get_note_updates(submission_height, tx_result).await?;
809
810        // Only expected input notes need tags; output notes are committed (with proofs)
811        // via account-matched transaction sync.
812        let new_tags: Vec<NoteTagRecord> = note_updates
813            .updated_input_notes()
814            .filter_map(|note| {
815                let note = note.inner();
816
817                if let InputNoteState::Expected(ExpectedNoteState { tag: Some(tag), .. }) =
818                    note.state()
819                {
820                    Some(NoteTagRecord::with_note_source(*tag, note.details_commitment()))
821                } else {
822                    None
823                }
824            })
825            .collect();
826
827        Ok(TransactionStoreUpdate::new(
828            tx_result.executed_transaction().clone(),
829            submission_height,
830            note_updates,
831            tx_result.future_notes().to_vec(),
832            new_tags,
833        ))
834    }
835
836    /// Persists the effects of a submitted transaction into the local store,
837    /// updating account data, note metadata, and future note tracking.
838    pub async fn apply_transaction(
839        &self,
840        tx_result: &TransactionResult,
841        submission_height: BlockNumber,
842    ) -> Result<(), ClientError> {
843        let tx_update = self.get_transaction_store_update(tx_result, submission_height).await?;
844
845        self.apply_transaction_update(tx_update).await?;
846
847        // Fire transaction observers. Per-observer failures are logged.
848        for observer in &self.transaction_observers {
849            if let Err(err) = observer.apply(tx_result).await {
850                tracing::warn!(
851                    observer = observer.name(),
852                    error = ?err,
853                    "TransactionObserver::apply failed; continuing with remaining observers",
854                );
855            }
856        }
857
858        Ok(())
859    }
860
861    pub async fn apply_transaction_update(
862        &self,
863        tx_update: TransactionStoreUpdate,
864    ) -> Result<(), ClientError> {
865        // Transaction was proven and submitted to the node correctly, persist note details and
866        // update account
867        info!("Applying transaction to the local store...");
868
869        let executed_transaction = tx_update.executed_transaction();
870        let account_id = executed_transaction.account_id();
871
872        if self.account_reader(account_id).status().await?.is_locked() {
873            return Err(ClientError::AccountLocked(account_id));
874        }
875
876        self.store.apply_transaction(tx_update).await?;
877        info!("Transaction stored.");
878        Ok(())
879    }
880
881    /// Executes the provided transaction script against the specified account, and returns the
882    /// resulting stack. Advice inputs and foreign accounts can be provided for the execution.
883    ///
884    /// The transaction will use the current sync height as the block reference.
885    pub async fn execute_program(
886        &mut self,
887        account_id: AccountId,
888        tx_script: TransactionScript,
889        advice_inputs: AdviceInputs,
890        foreign_accounts: BTreeMap<AccountId, ForeignAccount>,
891    ) -> Result<[Felt; MIN_STACK_DEPTH], ClientError> {
892        let (data_store, block_ref) =
893            self.prepare_program_execution(account_id, foreign_accounts).await?;
894
895        Ok(self
896            .build_executor(&data_store)?
897            .execute_tx_view_script(account_id, block_ref, tx_script, advice_inputs)
898            .await?)
899    }
900
901    /// Executes the provided transaction script with a DAP debug adapter listening for
902    /// connections, allowing interactive debugging via any DAP-compatible client.
903    #[cfg(feature = "dap")]
904    pub async fn execute_program_with_dap(
905        &mut self,
906        account_id: AccountId,
907        tx_script: TransactionScript,
908        advice_inputs: AdviceInputs,
909        foreign_accounts: BTreeMap<AccountId, ForeignAccount>,
910    ) -> Result<[Felt; MIN_STACK_DEPTH], ClientError> {
911        let (data_store, block_ref) =
912            self.prepare_program_execution(account_id, foreign_accounts).await?;
913
914        Ok(self
915            .build_dap_executor(&data_store)?
916            .execute_tx_view_script(account_id, block_ref, tx_script, advice_inputs)
917            .await?)
918    }
919
920    // HELPERS
921    // --------------------------------------------------------------------------------------------
922
923    /// Validates that the specified transaction request can be executed by the specified account.
924    ///
925    /// This does't guarantee that the transaction will succeed, but it's useful to avoid submitting
926    /// transactions that are guaranteed to fail. Some of the validations include:
927    /// - That the account has enough balance to cover the outgoing assets.
928    /// - That the client is not too far behind the chain tip.
929    pub async fn validate_request(
930        &self,
931        account_id: AccountId,
932        transaction_request: &TransactionRequest,
933    ) -> Result<(), ClientError> {
934        self.validate_recency().await?;
935        validate_output_note_senders(transaction_request, account_id)?;
936        let account = self.try_get_account(account_id).await?;
937        validate_account_request(transaction_request, &account)
938    }
939
940    async fn validate_recency(&self) -> Result<(), ClientError> {
941        if let Some(max_block_number_delta) = self.max_block_number_delta {
942            let current_chain_tip =
943                self.rpc_api.get_block_header_by_number(None, false).await?.0.block_num();
944
945            if current_chain_tip > self.store.get_sync_height().await? + max_block_number_delta {
946                return Err(ClientError::RecencyConditionError(
947                    "The client is too far behind the chain tip to execute the transaction",
948                ));
949            }
950        }
951        Ok(())
952    }
953
954    /// Checks whether the node's `note_scripts` registry already has each of the expected NTX
955    /// scripts. For any script that is missing, creates and submits a registration transaction
956    /// that produces a public note carrying that script.
957    ///
958    /// `account_id` is the account that will execute the registration transaction.
959    ///
960    /// Standard note scripts are skipped — the NTX builder resolves those directly, so they
961    /// never need registering. A missing non-standard script is registered, not an error.
962    ///
963    /// This method is called automatically by [`Self::submit_new_transaction_with_prover`] when the
964    /// [`TransactionRequest`] contains expected NTX scripts. It can also be called directly if
965    /// you want to register scripts ahead of time.
966    pub async fn ensure_ntx_scripts_registered(
967        &mut self,
968        account_id: AccountId,
969        scripts: &[NoteScript],
970        tx_prover: Arc<dyn TransactionProver>,
971    ) -> Result<(), ClientError> {
972        let mut missing_scripts = Vec::new();
973
974        for script in scripts {
975            // Standard scripts are resolved by the NTX builder directly; no registration needed.
976            if StandardNote::from_script(script).is_some() {
977                continue;
978            }
979
980            let script_root = script.root();
981
982            // Scripts the node doesn't have are queued for registration; only RPC errors abort.
983            match self.rpc_api.get_note_script_by_root(script_root.into()).await {
984                Ok(Some(_)) => {},
985                Ok(None) => missing_scripts.push(script.clone()),
986                Err(source) => {
987                    return Err(ClientError::NtxScriptRegistrationFailed {
988                        script_root: script_root.into(),
989                        source,
990                    });
991                },
992            }
993        }
994
995        if missing_scripts.is_empty() {
996            return Ok(());
997        }
998
999        let registration_request = TransactionRequestBuilder::new().build_register_note_scripts(
1000            account_id,
1001            missing_scripts,
1002            self.rng(),
1003        )?;
1004
1005        let tx_result = self.execute_transaction(account_id, registration_request).await?;
1006        let proven = self.prove_transaction_with(&tx_result, tx_prover).await?;
1007        let submission_height = self.submit_proven_transaction(proven, &tx_result).await?;
1008        self.apply_transaction(&tx_result, submission_height).await?;
1009
1010        Ok(())
1011    }
1012
1013    /// Filters the provided input notes down to the subset that can be consumed by the account.
1014    ///
1015    /// The trial runs against `data_store` at `block_ref`, which must match the reference block
1016    /// the actual execution will use.
1017    pub(crate) async fn get_valid_input_notes<STORE: DataStore + Sync>(
1018        &self,
1019        data_store: &STORE,
1020        account_id: AccountId,
1021        block_ref: BlockNumber,
1022        mut input_notes: InputNotes<InputNote>,
1023        tx_args: TransactionArgs,
1024    ) -> Result<InputNotes<InputNote>, ClientError> {
1025        loop {
1026            // The consumption checker rejects a zero-note call; the set can be empty because the
1027            // request carried no notes or because screening removed them all.
1028            if input_notes.is_empty() {
1029                break;
1030            }
1031
1032            let execution = NoteConsumptionChecker::new(&self.build_executor(data_store)?)
1033                .check_notes_consumability(
1034                    account_id,
1035                    block_ref,
1036                    input_notes.iter().map(|n| n.clone().into_note()).collect(),
1037                    tx_args.clone(),
1038                )
1039                .await?;
1040
1041            if execution.failed().is_empty() {
1042                break;
1043            }
1044
1045            let failed_note_ids: BTreeSet<NoteId> =
1046                execution.failed().iter().map(|n| n.note().id()).collect();
1047            let filtered_input_notes = InputNotes::new(
1048                input_notes
1049                    .into_iter()
1050                    .filter(|note| !failed_note_ids.contains(&note.id()))
1051                    .collect(),
1052            )
1053            .expect("Created from a valid input notes list");
1054
1055            input_notes = filtered_input_notes;
1056        }
1057
1058        Ok(input_notes)
1059    }
1060
1061    /// Returns foreign account inputs for the required foreign accounts specified by the
1062    /// transaction request, with proofs anchored at `block_num` — the transaction's reference
1063    /// block, so that the fetched state is consistent with the block the transaction executes
1064    /// against.
1065    ///
1066    /// For any [`ForeignAccount::Public`] in `foreign_accounts`, these pieces of data are retrieved
1067    /// from the network. For any [`ForeignAccount::Private`] account, inner data is used and only
1068    /// a proof of the account's existence on the network is fetched.
1069    async fn retrieve_foreign_account_inputs(
1070        &self,
1071        foreign_accounts: BTreeMap<AccountId, ForeignAccount>,
1072        block_num: BlockNumber,
1073    ) -> Result<Vec<AccountInputs>, ClientError> {
1074        if foreign_accounts.is_empty() {
1075            return Ok(Vec::new());
1076        }
1077
1078        let mut return_foreign_account_inputs = Vec::with_capacity(foreign_accounts.len());
1079
1080        for foreign_account in foreign_accounts.into_values() {
1081            let foreign_account_inputs = match foreign_account {
1082                ForeignAccount::Public(account_id, storage_requirements) => {
1083                    fetch_public_account_inputs(
1084                        &self.store,
1085                        &self.rpc_api,
1086                        account_id,
1087                        storage_requirements,
1088                        AccountStateAt::Block(block_num),
1089                    )
1090                    .await?
1091                },
1092                ForeignAccount::Private(partial_account) => {
1093                    let account_id = partial_account.id();
1094                    let (_, account_proof) = self
1095                        .rpc_api
1096                        .get_account(
1097                            account_id,
1098                            GetAccountRequest::new().at(AccountStateAt::Block(block_num)),
1099                        )
1100                        .await?;
1101                    let (witness, _) = account_proof.into_parts();
1102                    AccountInputs::new(partial_account, witness)
1103                },
1104            };
1105
1106            return_foreign_account_inputs.push(foreign_account_inputs);
1107        }
1108
1109        Ok(return_foreign_account_inputs)
1110    }
1111
1112    /// Prepares the data store and block reference for program execution.
1113    ///
1114    /// This is shared setup for both `execute_program` and `execute_program_with_dap`.
1115    async fn prepare_program_execution(
1116        &mut self,
1117        account_id: AccountId,
1118        foreign_accounts: BTreeMap<AccountId, ForeignAccount>,
1119    ) -> Result<(ClientDataStore, BlockNumber), ClientError> {
1120        let block_ref = self.get_sync_height().await?;
1121
1122        let foreign_account_inputs =
1123            self.retrieve_foreign_account_inputs(foreign_accounts, block_ref).await?;
1124
1125        let account_code = self
1126            .store
1127            .get_account_code(account_id)
1128            .await?
1129            .ok_or(ClientError::AccountDataNotFound(account_id))?;
1130
1131        let data_store = ClientDataStore::new(self.store.clone(), self.rpc_api.clone());
1132
1133        // Ensure code is loaded on MAST store
1134        data_store.mast_store().load_account_code(&account_code);
1135
1136        for fpi_account in &foreign_account_inputs {
1137            data_store.mast_store().load_account_code(fpi_account.code());
1138        }
1139
1140        data_store.register_foreign_account_inputs(foreign_account_inputs);
1141
1142        Ok((data_store, block_ref))
1143    }
1144
1145    /// Creates a transaction executor configured with the client's runtime options,
1146    /// authenticator, and source manager.
1147    pub(crate) fn build_executor<'store, 'auth, STORE: DataStore + Sync>(
1148        &'auth self,
1149        data_store: &'store STORE,
1150    ) -> Result<TransactionExecutor<'store, 'auth, STORE, AUTH>, TransactionExecutorError> {
1151        let mut executor = TransactionExecutor::new(data_store)
1152            .with_options(self.exec_options)?
1153            .with_source_manager(self.source_manager.clone());
1154        if let Some(authenticator) = self.authenticator.as_deref() {
1155            executor = executor.with_authenticator(authenticator);
1156        }
1157        Ok(executor)
1158    }
1159
1160    /// Loads an [`AccountRecord`] for an account that must be usable as a transaction's native
1161    /// account. Errors out if the account is not tracked or if it is watched.
1162    async fn get_native_account_record(
1163        &self,
1164        account_id: AccountId,
1165    ) -> Result<AccountRecord, ClientError> {
1166        let account_record = self
1167            .store
1168            .get_account(account_id)
1169            .await?
1170            .ok_or(ClientError::AccountDataNotFound(account_id))?;
1171        if account_record.is_watched() {
1172            return Err(ClientError::AccountIsWatched(account_id));
1173        }
1174        Ok(account_record)
1175    }
1176
1177    /// Creates a transaction executor configured for DAP (Debug Adapter Protocol) debugging.
1178    #[cfg(feature = "dap")]
1179    pub(crate) fn build_dap_executor<'store, 'auth, STORE: DataStore + Sync>(
1180        &'auth self,
1181        data_store: &'store STORE,
1182    ) -> Result<
1183        TransactionExecutor<'store, 'auth, STORE, AUTH, dap_executor::DapProgramExecutor>,
1184        TransactionExecutorError,
1185    > {
1186        Ok(self
1187            .build_executor(data_store)?
1188            .with_program_executor::<dap_executor::DapProgramExecutor>())
1189    }
1190
1191    /// Returns [`NoteUpdateTracker`] containing the note updates generated by an executed
1192    /// transaction.
1193    async fn get_note_updates(
1194        &self,
1195        submission_height: BlockNumber,
1196        tx_result: &TransactionResult,
1197    ) -> Result<NoteUpdateTracker, TransactionStoreUpdateError> {
1198        let executed_tx = tx_result.executed_transaction();
1199        let current_timestamp = self.store.get_current_timestamp();
1200        let current_block_num = self.store.get_sync_height().await?;
1201
1202        // New output notes
1203        let new_output_notes = executed_tx
1204            .output_notes()
1205            .iter()
1206            .cloned()
1207            .filter_map(|output_note| {
1208                OutputNoteRecord::try_from_output_note(output_note, submission_height).ok()
1209            })
1210            .collect::<Vec<_>>();
1211
1212        // New relevant input notes
1213        let mut new_input_notes = vec![];
1214        let output_notes: Vec<Note> =
1215            notes_from_output(executed_tx.output_notes()).cloned().collect();
1216        let note_screener = self.note_screener().clone();
1217        let output_note_relevances = note_screener.get_batch_consumability(&output_notes).await?;
1218
1219        for note in output_notes {
1220            if output_note_relevances.contains_key(&note.id()) {
1221                let metadata = *note.metadata();
1222                let tag = metadata.tag();
1223                let attachments = note.attachments().clone();
1224
1225                new_input_notes.push(InputNoteRecord::new(
1226                    note.into(),
1227                    attachments,
1228                    current_timestamp,
1229                    ExpectedNoteState {
1230                        metadata: Some(metadata),
1231                        after_block_num: submission_height,
1232                        tag: Some(tag),
1233                    }
1234                    .into(),
1235                ));
1236            }
1237        }
1238
1239        // Track future input notes described in the transaction result.
1240        new_input_notes.extend(tx_result.future_notes().iter().map(|(note_details, tag)| {
1241            InputNoteRecord::new(
1242                note_details.clone(),
1243                NoteAttachments::empty(),
1244                None,
1245                ExpectedNoteState {
1246                    metadata: None,
1247                    after_block_num: current_block_num,
1248                    tag: Some(*tag),
1249                }
1250                .into(),
1251            )
1252        }));
1253
1254        // Locally consumed notes. Notes already tracked by the store only need their state
1255        // advanced; the rest (the request's unauthenticated notes, which are not persisted
1256        // before the transaction succeeds) are tracked from this point on, so records for them
1257        // are built from the executed transaction's inputs.
1258        let consumed_note_ids =
1259            executed_tx.tx_inputs().input_notes().iter().map(InputNote::id).collect();
1260
1261        let consumed_notes =
1262            self.store.get_input_notes(NoteFilter::List(consumed_note_ids)).await?;
1263
1264        let tracked_note_ids =
1265            consumed_notes.iter().filter_map(InputNoteRecord::id).collect::<BTreeSet<_>>();
1266
1267        for input_note in executed_tx.tx_inputs().input_notes() {
1268            if !tracked_note_ids.contains(&input_note.id()) {
1269                let mut input_note_record = InputNoteRecord::from(input_note.clone());
1270                input_note_record.consumed_locally(
1271                    executed_tx.account_id(),
1272                    executed_tx.id(),
1273                    current_timestamp,
1274                )?;
1275                new_input_notes.push(input_note_record);
1276            }
1277        }
1278
1279        let mut updated_input_notes = vec![];
1280
1281        for mut input_note_record in consumed_notes {
1282            if input_note_record.consumed_locally(
1283                executed_tx.account_id(),
1284                executed_tx.id(),
1285                current_timestamp,
1286            )? {
1287                updated_input_notes.push(input_note_record);
1288            }
1289        }
1290
1291        Ok(NoteUpdateTracker::for_transaction_updates(
1292            new_input_notes,
1293            updated_input_notes,
1294            new_output_notes,
1295        ))
1296    }
1297}
1298
1299// TRANSACTION STORE UPDATE ERROR
1300// ================================================================================================
1301
1302/// Error returned by [`Client::get_transaction_store_update`] when building the store update
1303/// for a submitted transaction fails.
1304#[derive(Debug, thiserror::Error)]
1305pub enum TransactionStoreUpdateError {
1306    #[error("store error")]
1307    Store(#[from] StoreError),
1308    #[error("note screener error")]
1309    NoteScreener(#[from] NoteScreenerError),
1310    #[error("note record error")]
1311    NoteRecord(#[from] NoteRecordError),
1312}
1313
1314// HELPERS
1315// ================================================================================================
1316
1317#[derive(Clone, Copy, Debug)]
1318enum TransactionExecutionMode {
1319    Standard,
1320    #[cfg(feature = "dap")]
1321    Dap,
1322}
1323
1324/// Data-store-independent state produced during transaction preparation.
1325pub(crate) struct PreparedTransaction {
1326    pub(crate) notes: InputNotes<InputNote>,
1327    pub(crate) output_recipients: Vec<NoteRecipient>,
1328    pub(crate) future_notes: Vec<(NoteDetails, NoteTag)>,
1329    pub(crate) tx_args: TransactionArgs,
1330    pub(crate) foreign_account_inputs: Vec<AccountInputs>,
1331    pub(crate) block_num: BlockNumber,
1332    pub(crate) ignore_invalid_notes: bool,
1333}
1334
1335impl PreparedTransaction {
1336    /// Returns the scripts of the request's expected output notes. These must be registered on
1337    /// the executor's data store so output note creation can resolve them during execution.
1338    pub(crate) fn output_note_scripts(&self) -> impl Iterator<Item = NoteScript> + '_ {
1339        self.output_recipients.iter().map(|recipient| recipient.script().clone())
1340    }
1341}
1342
1343/// Helper to get the account outgoing assets.
1344///
1345/// Any outgoing assets resulting from executing note scripts but not present in expected output
1346/// notes wouldn't be included.
1347fn get_outgoing_assets(
1348    transaction_request: &TransactionRequest,
1349) -> (BTreeMap<AccountId, u64>, Vec<NonFungibleAsset>) {
1350    // Get own notes assets
1351    let mut own_notes_assets = match transaction_request.script_template() {
1352        Some(TransactionScriptTemplate::SendNotes(notes)) => notes
1353            .iter()
1354            .map(|note| (note.id(), note.assets().clone()))
1355            .collect::<BTreeMap<_, _>>(),
1356        _ => BTreeMap::default(),
1357    };
1358    // Get transaction output notes assets
1359    let mut output_notes_assets = transaction_request
1360        .expected_output_own_notes()
1361        .into_iter()
1362        .map(|note| (note.id(), note.assets().clone()))
1363        .collect::<BTreeMap<_, _>>();
1364
1365    // Merge with own notes assets and delete duplicates
1366    output_notes_assets.append(&mut own_notes_assets);
1367
1368    // Create a map of the fungible and non-fungible assets in the output notes
1369    let outgoing_assets = output_notes_assets.values().flat_map(|note_assets| note_assets.iter());
1370
1371    request::collect_assets(outgoing_assets)
1372}
1373
1374/// Validates a transaction request against the supplied `account`. Faucets are currently
1375/// skipped; for non-faucets, defers to [`validate_basic_account_request`] for asset-balance
1376/// checks.
1377pub(super) fn validate_account_request(
1378    transaction_request: &TransactionRequest,
1379    account: &Account,
1380) -> Result<(), ClientError> {
1381    validate_fee_conversion_info_support(transaction_request, account)?;
1382
1383    if account.code_interface().contains([FungibleFaucet::mint_and_send_root()]) {
1384        // TODO(SantiagoPittella): Add faucet validations.
1385        Ok(())
1386    } else {
1387        validate_basic_account_request(transaction_request, account)
1388    }
1389}
1390
1391/// Verifies that the account can consume fee conversion info passed through the auth args.
1392///
1393/// Only the signature-based auth components read the auth args as conversion info (through
1394/// `miden::standards::fee`). On any other auth component the declared asset and rate would be
1395/// silently ignored and the fee paid in the chain's native asset, so the request is rejected here
1396/// instead.
1397fn validate_fee_conversion_info_support(
1398    transaction_request: &TransactionRequest,
1399    account: &Account,
1400) -> Result<(), ClientError> {
1401    if !transaction_request.declares_fee_conversion_info() {
1402        return Ok(());
1403    }
1404
1405    let interface = AccountInterface::from_account(account);
1406    let auth_component = interface.auth_component();
1407    if matches!(
1408        auth_component,
1409        AccountComponentInterface::AuthSingleSig | AccountComponentInterface::AuthMultisig
1410    ) {
1411        return Ok(());
1412    }
1413
1414    Err(ClientError::TransactionRequestError(
1415        TransactionRequestError::FeeConversionInfoUnsupported(auth_component.name()),
1416    ))
1417}
1418
1419/// Verifies that every output note emitted directly by the transaction declares `account_id` as
1420/// its sender.
1421///
1422/// A note's sender is bound by the kernel to the account that emits it, and note scripts (e.g.
1423/// P2IDE reclaim) authorize on that field, so an output note declaring a foreign sender can never
1424/// be executed. Catching it here yields a clear, immediate error instead of a cryptic failure deep
1425/// in transaction script building.
1426fn validate_output_note_senders(
1427    transaction_request: &TransactionRequest,
1428    account_id: AccountId,
1429) -> Result<(), ClientError> {
1430    for note in transaction_request.expected_output_own_notes() {
1431        let sender = note.metadata().sender();
1432        if sender != account_id {
1433            return Err(ClientError::TransactionRequestError(
1434                TransactionRequestError::OutputNoteSenderMismatch {
1435                    expected: account_id,
1436                    actual: sender,
1437                },
1438            ));
1439        }
1440    }
1441
1442    Ok(())
1443}
1444
1445/// Ensures a transaction request is compatible with the current account state,
1446/// primarily by checking asset balances against the requested transfers.
1447fn validate_basic_account_request(
1448    transaction_request: &TransactionRequest,
1449    account: &Account,
1450) -> Result<(), ClientError> {
1451    // Get outgoing assets
1452    let (fungible_balance_map, non_fungible_set) = get_outgoing_assets(transaction_request);
1453
1454    // Get incoming assets
1455    let (incoming_fungible_balance_map, incoming_non_fungible_balance_set) =
1456        transaction_request.incoming_assets();
1457
1458    // Aggregate the account's fungible balance per faucet in one pass. A faucet's fungible asset
1459    // may occupy more than one callback-flag vault key, so all matching entries are summed.
1460    let mut available_fungible: BTreeMap<AccountId, u64> = BTreeMap::new();
1461    for asset in account.vault().assets() {
1462        if let Asset::Fungible(fungible) = asset {
1463            let balance = available_fungible.entry(fungible.faucet_id()).or_default();
1464            *balance = balance.saturating_add(fungible.amount().as_u64());
1465        }
1466    }
1467
1468    // Check if the account balance plus incoming assets is greater than or equal to the
1469    // outgoing fungible assets
1470    for (faucet_id, amount) in fungible_balance_map {
1471        let account_asset_amount = available_fungible.get(&faucet_id).copied().unwrap_or(0);
1472        let incoming_balance = incoming_fungible_balance_map.get(&faucet_id).unwrap_or(&0);
1473        if account_asset_amount + incoming_balance < amount {
1474            return Err(ClientError::AssetError(AssetError::FungibleAssetAmountNotSufficient {
1475                minuend: account_asset_amount,
1476                subtrahend: amount,
1477            }));
1478        }
1479    }
1480
1481    // Check if the account balance plus incoming assets is greater than or equal to the
1482    // outgoing non fungible assets
1483    for non_fungible in &non_fungible_set {
1484        match account.vault().has_non_fungible_asset(*non_fungible) {
1485            Ok(true) => (),
1486            Ok(false) => {
1487                // Check if the non fungible asset is in the incoming assets
1488                if !incoming_non_fungible_balance_set.contains(non_fungible) {
1489                    return Err(ClientError::TransactionRequestError(
1490                        TransactionRequestError::MissingNonFungibleAsset(non_fungible.faucet_id()),
1491                    ));
1492                }
1493            },
1494            _ => {
1495                return Err(ClientError::TransactionRequestError(
1496                    TransactionRequestError::MissingNonFungibleAsset(non_fungible.faucet_id()),
1497                ));
1498            },
1499        }
1500    }
1501
1502    Ok(())
1503}
1504
1505/// Fetches a foreign account's proof and details from the network, converts them into
1506/// [`AccountInputs`], and caches the returned code in the store for future requests.
1507///
1508/// Storage maps the node caps as oversized (returned truncated) are carried root-only in the
1509/// inputs; reads from them resolve lazily as per-key witnesses during execution.
1510///
1511/// # Errors
1512/// Fails if the account is private: the RPC does not return account details for them, causing
1513/// [`TransactionRequestError::ForeignAccountDataMissing`].
1514pub(crate) async fn fetch_public_account_inputs(
1515    store: &Arc<dyn Store>,
1516    rpc_api: &Arc<dyn NodeRpcClient>,
1517    account_id: AccountId,
1518    storage_requirements: AccountStorageRequirements,
1519    account_state_at: AccountStateAt,
1520) -> Result<AccountInputs, ClientError> {
1521    let known_code: Option<AccountCode> =
1522        store.get_foreign_account_code(vec![account_id]).await?.into_values().next();
1523
1524    // Tracked accounts skip the asset list when unchanged; untracked accounts fetch it in full
1525    // so asset reads need no execution-time RPC.
1526    let vault = store
1527        .get_account_header(account_id)
1528        .await?
1529        .map_or(VaultFetch::Always, |(header, ..)| {
1530            VaultFetch::IfChangedFrom(header.vault_root())
1531        });
1532
1533    let (_block_num, account_proof) = rpc_api
1534        .get_account(
1535            account_id,
1536            GetAccountRequest::new()
1537                .with_storage(StorageMapFetch::Slots(storage_requirements.clone()))
1538                .at(account_state_at)
1539                .with_known_code(known_code)
1540                .with_vault(vault),
1541        )
1542        .await?;
1543
1544    let account_inputs = request::account_proof_into_inputs(account_proof, &storage_requirements)?;
1545
1546    let _ = store
1547        .upsert_foreign_account_code(account_id, account_inputs.code().clone())
1548        .await
1549        .inspect_err(|err| {
1550            tracing::warn!(
1551                %account_id,
1552                %err,
1553                "Failed to persist foreign account code to store"
1554            );
1555        });
1556
1557    Ok(account_inputs)
1558}
1559
1560/// Extracts notes from [`RawOutputNotes`].
1561/// Used for:
1562/// - Checking the relevance of notes to save them as input notes.
1563/// - Validate hashes versus expected output notes after a transaction is executed.
1564pub fn notes_from_output(output_notes: &RawOutputNotes) -> impl Iterator<Item = &Note> {
1565    output_notes.iter().filter_map(|n| match n {
1566        RawOutputNote::Full(n) => Some(n),
1567        RawOutputNote::Partial(_) => None,
1568    })
1569}
1570
1571/// Validates that the executed transaction's output recipients match what was expected in the
1572/// transaction request.
1573pub(crate) fn validate_executed_transaction(
1574    executed_transaction: &ExecutedTransaction,
1575    expected_output_recipients: &[NoteRecipient],
1576) -> Result<(), ClientError> {
1577    let tx_output_recipient_digests = executed_transaction
1578        .output_notes()
1579        .iter()
1580        .filter_map(|n| n.recipient().map(NoteRecipient::digest))
1581        .collect::<Vec<_>>();
1582
1583    let missing_recipient_digest: Vec<Word> = expected_output_recipients
1584        .iter()
1585        .filter_map(|recipient| {
1586            (!tx_output_recipient_digests.contains(&recipient.digest()))
1587                .then_some(recipient.digest())
1588        })
1589        .collect();
1590
1591    if !missing_recipient_digest.is_empty() {
1592        return Err(ClientError::MissingOutputRecipients(missing_recipient_digest));
1593    }
1594
1595    Ok(())
1596}
1597
1598// TESTS
1599// ================================================================================================
1600
1601#[cfg(test)]
1602mod tests {
1603    use alloc::vec;
1604
1605    use miden_protocol::Word;
1606    use miden_protocol::account::auth::AuthSecretKey;
1607    use miden_protocol::account::{AccountBuilder, AccountComponent, AccountId, AccountType};
1608    use miden_protocol::asset::FungibleAsset;
1609    use miden_protocol::crypto::rand::RandomCoin;
1610    use miden_protocol::note::{Note, NoteType};
1611    use miden_protocol::testing::account_id::{
1612        ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
1613        ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
1614        ACCOUNT_ID_SENDER,
1615    };
1616    use miden_standards::account::AccountBuilderSchemaCommitmentExt;
1617    use miden_standards::account::auth::{Approver, AuthSingleSig, FeeConversionInfo, NoAuth};
1618    use miden_standards::account::wallets::BasicWallet;
1619    use miden_standards::note::P2idNote;
1620
1621    use super::{
1622        Account,
1623        AccountComponentInterface,
1624        TransactionRequest,
1625        TransactionRequestBuilder,
1626        validate_fee_conversion_info_support,
1627        validate_output_note_senders,
1628    };
1629    use crate::ClientError;
1630    use crate::auth::AuthSchemeId;
1631    use crate::transaction::TransactionRequestError;
1632
1633    fn own_note_with_sender(sender: AccountId) -> Note {
1634        let faucet_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap();
1635        let target_id =
1636            AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();
1637        let mut rng = RandomCoin::new(Word::default());
1638
1639        P2idNote::builder()
1640            .sender(sender)
1641            .target(target_id)
1642            .asset(FungibleAsset::new(faucet_id, 100).unwrap())
1643            .note_type(NoteType::Public)
1644            .generate_serial_number(&mut rng)
1645            .build()
1646            .expect("note creation failed")
1647            .into()
1648    }
1649
1650    #[test]
1651    fn output_note_with_foreign_sender_is_rejected() {
1652        let account_id =
1653            AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();
1654        let foreign_sender = AccountId::try_from(ACCOUNT_ID_SENDER).unwrap();
1655        assert_ne!(account_id, foreign_sender);
1656
1657        let request = TransactionRequestBuilder::new()
1658            .own_output_notes(vec![own_note_with_sender(foreign_sender)])
1659            .build()
1660            .unwrap();
1661
1662        let err = validate_output_note_senders(&request, account_id).unwrap_err();
1663        match err {
1664            ClientError::TransactionRequestError(
1665                TransactionRequestError::OutputNoteSenderMismatch { expected, actual },
1666            ) => {
1667                assert_eq!(expected, account_id);
1668                assert_eq!(actual, foreign_sender);
1669            },
1670            other => panic!("expected OutputNoteSenderMismatch, got {other:?}"),
1671        }
1672    }
1673
1674    #[test]
1675    fn output_note_with_matching_sender_is_accepted() {
1676        let account_id =
1677            AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();
1678
1679        let request = TransactionRequestBuilder::new()
1680            .own_output_notes(vec![own_note_with_sender(account_id)])
1681            .build()
1682            .unwrap();
1683
1684        validate_output_note_senders(&request, account_id).unwrap();
1685    }
1686
1687    #[test]
1688    fn request_without_own_output_notes_is_accepted() {
1689        let account_id =
1690            AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();
1691        let faucet_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap();
1692
1693        // A consume-only request (input note, no own output notes) must pass the sender check.
1694        let request = TransactionRequestBuilder::new()
1695            .input_notes(vec![(own_note_with_sender(faucet_id), None)])
1696            .build()
1697            .unwrap();
1698
1699        validate_output_note_senders(&request, account_id).unwrap();
1700    }
1701
1702    /// Builds an account carrying `auth_component` and a basic wallet.
1703    fn account_with_auth(auth_component: impl Into<AccountComponent>) -> Account {
1704        AccountBuilder::new([7u8; 32])
1705            .account_type(AccountType::Public)
1706            .with_component(auth_component)
1707            .with_component(BasicWallet)
1708            .build_with_schema_commitment()
1709            .expect("account creation failed")
1710    }
1711
1712    fn fee_conversion_request() -> TransactionRequest {
1713        let faucet_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap();
1714
1715        TransactionRequestBuilder::new()
1716            .fee_conversion_info(FeeConversionInfo::one_to_one(faucet_id), Word::default())
1717            .build()
1718            .unwrap()
1719    }
1720
1721    #[test]
1722    fn fee_conversion_info_is_accepted_by_a_signature_authenticated_account() {
1723        let key = AuthSecretKey::new_falcon512_poseidon2();
1724        let auth = AuthSingleSig::new(Approver::new(
1725            key.public_key().to_commitment(),
1726            AuthSchemeId::Falcon512Poseidon2,
1727        ));
1728
1729        validate_fee_conversion_info_support(&fee_conversion_request(), &account_with_auth(auth))
1730            .unwrap();
1731    }
1732
1733    #[test]
1734    fn fee_conversion_info_is_rejected_by_an_account_that_cannot_read_it() {
1735        let account = account_with_auth(NoAuth);
1736
1737        let err = validate_fee_conversion_info_support(&fee_conversion_request(), &account)
1738            .expect_err("NoAuth does not read the auth args");
1739        match err {
1740            ClientError::TransactionRequestError(
1741                TransactionRequestError::FeeConversionInfoUnsupported(auth_component),
1742            ) => assert_eq!(auth_component, AccountComponentInterface::AuthNoAuth.name()),
1743            other => panic!("expected FeeConversionInfoUnsupported, got {other:?}"),
1744        }
1745    }
1746
1747    #[test]
1748    fn a_request_without_fee_conversion_info_skips_the_auth_component_check() {
1749        // `NoAuth` cannot read conversion info, but a request that declares none is unaffected.
1750        validate_fee_conversion_info_support(
1751            &TransactionRequestBuilder::new().build().unwrap(),
1752            &account_with_auth(NoAuth),
1753        )
1754        .unwrap();
1755    }
1756}