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