Skip to main content

miden_client/transaction/request/
mod.rs

1//! Contains structures and functions related to transaction creation.
2
3use alloc::boxed::Box;
4use alloc::collections::{BTreeMap, BTreeSet};
5use alloc::string::{String, ToString};
6use alloc::vec::Vec;
7use core::num::NonZeroU16;
8
9use miden_protocol::Word;
10use miden_protocol::account::{AccountCodeInterface, AccountId};
11use miden_protocol::asset::{Asset, NonFungibleAsset};
12use miden_protocol::block::BlockNumber;
13use miden_protocol::crypto::merkle::MerkleError;
14use miden_protocol::crypto::merkle::store::MerkleStore;
15use miden_protocol::errors::{
16    AccountError,
17    AssetError,
18    AssetVaultError,
19    NoteError,
20    StorageMapError,
21    TransactionInputError,
22    TransactionScriptError,
23};
24use miden_protocol::note::{
25    Note,
26    NoteDetails,
27    NoteDetailsCommitment,
28    NoteId,
29    NoteRecipient,
30    NoteScript,
31    NoteTag,
32    PartialNote,
33};
34use miden_protocol::transaction::{InputNote, InputNotes, TransactionArgs, TransactionScript};
35use miden_protocol::vm::AdviceMap;
36use miden_standards::account::auth::{FeeConversionInfo, commit_fee_conversion_info};
37use miden_standards::errors::CodeBuilderError;
38use miden_standards::tx_script::{SendNotesTransactionScript, SendNotesTransactionScriptError};
39use miden_tx::utils::serde::{
40    ByteReader,
41    ByteWriter,
42    Deserializable,
43    DeserializationError,
44    Serializable,
45};
46use thiserror::Error;
47
48mod builder;
49pub use builder::{
50    PaymentNoteDescription,
51    PswapTransactionData,
52    SwapTransactionData,
53    TransactionRequestBuilder,
54};
55
56mod foreign;
57pub(crate) use foreign::account_proof_into_inputs;
58pub use foreign::{ForeignAccount, build_fpi_script};
59
60use crate::store::InputNoteRecord;
61
62// TRANSACTION REQUEST
63// ================================================================================================
64
65pub type NoteArgs = Word;
66
67/// Specifies a transaction script to be executed in a transaction.
68///
69/// A transaction script is a program which is executed after scripts of all input notes have been
70/// executed.
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub enum TransactionScriptTemplate {
73    /// Specifies the exact transaction script to be executed in a transaction.
74    CustomScript(TransactionScript),
75    /// Specifies that the transaction script must create the specified output notes.
76    ///
77    /// It is up to the client to determine how the output notes will be created and this will
78    /// depend on the capabilities of the account the transaction request will be applied to.
79    /// For example, for Basic Wallets, this may involve invoking `create_note` procedure.
80    SendNotes(Vec<PartialNote>),
81}
82
83/// Specifies a transaction request that can be executed by an account.
84///
85/// A request contains information about input notes to be consumed by the transaction (if any),
86/// description of the transaction script to be executed (if any), and a set of notes expected
87/// to be generated by the transaction or by consuming notes generated by the transaction.
88#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct TransactionRequest {
90    /// Notes to be consumed by the transaction, in consumption order.
91    ///
92    /// A note with an entry in `explicit_input_notes` is consumed in the mode that entry pins.
93    /// The executing client infers the mode of every other note from its store.
94    input_notes: Vec<Note>,
95    /// Optional arguments of the input notes to be consumed by the transaction. This
96    /// includes both authenticated and unauthenticated notes.
97    input_notes_args: Vec<(NoteId, Option<NoteArgs>)>,
98    /// Pinned consumption modes set through
99    /// [`TransactionRequestBuilder::explicit_input_notes`].
100    pub(super) explicit_input_notes: BTreeMap<NoteId, InputNote>,
101    /// Template for the creation of the transaction script.
102    script_template: Option<TransactionScriptTemplate>,
103    /// A map of recipients of the output notes expected to be generated by the transaction.
104    expected_output_recipients: BTreeMap<Word, NoteRecipient>,
105    /// A map of details and tags of notes we expect to be created as part of future transactions
106    /// with their respective tags.
107    ///
108    /// For example, after a swap note is consumed, a payback note is expected to be created.
109    expected_future_notes: BTreeMap<NoteDetailsCommitment, (NoteDetails, NoteTag)>,
110    /// Initial state of the `AdviceMap` that provides data during runtime.
111    advice_map: AdviceMap,
112    /// Initial state of the `MerkleStore` that provides data during runtime.
113    merkle_store: MerkleStore,
114    /// Foreign account data requirements keyed by account ID. At execution time, account data
115    /// will be retrieved from the network, and injected as advice inputs. Additionally, the
116    /// account's code will be added to the executor and prover.
117    foreign_accounts: BTreeMap<AccountId, ForeignAccount>,
118    /// The number of blocks in relation to the transaction's reference block after which the
119    /// transaction will expire. If `None`, the transaction will not expire.
120    expiration_delta: Option<u16>,
121    /// Indicates whether to **silently** ignore invalid input notes when executing the
122    /// transaction. This will allow the transaction to be executed even if some input notes
123    /// are invalid.
124    ignore_invalid_input_notes: bool,
125    /// Optional [`Word`] that will be pushed to the operand stack before the transaction script
126    /// execution.
127    script_arg: Option<Word>,
128    /// Optional [`Word`] that will be pushed to the stack for the authentication procedure
129    /// during transaction execution.
130    auth_arg: Option<Word>,
131    /// Salt the native fee conversion info is committed under when the transaction is prepared,
132    /// set through [`TransactionRequestBuilder::fee_conversion_salt`]. `None` leaves the client
133    /// to use its fixed default salt.
134    fee_conversion_salt: Option<Word>,
135    /// Note scripts that the node's NTX builder will need in its script registry.
136    ///
137    /// See [`TransactionRequestBuilder::expected_ntx_scripts`] for details.
138    expected_ntx_scripts: Vec<NoteScript>,
139}
140
141impl TransactionRequest {
142    // PUBLIC ACCESSORS
143    // --------------------------------------------------------------------------------------------
144
145    /// Returns a reference to the transaction request's input note list.
146    pub fn input_notes(&self) -> &[Note] {
147        &self.input_notes
148    }
149
150    /// Returns a list of all input note IDs.
151    pub fn input_note_ids(&self) -> impl Iterator<Item = NoteId> {
152        self.input_notes.iter().map(Note::id)
153    }
154
155    /// Returns the assets held by the transaction's input notes.
156    pub fn incoming_assets(&self) -> (BTreeMap<AccountId, u64>, Vec<NonFungibleAsset>) {
157        collect_assets(self.input_notes.iter().flat_map(|note| note.assets().iter()))
158    }
159
160    /// Returns a map of note IDs to their respective [`NoteArgs`]. The result will include
161    /// exclusively note IDs for notes for which [`NoteArgs`] have been defined.
162    pub fn get_note_args(&self) -> BTreeMap<NoteId, NoteArgs> {
163        self.input_notes_args
164            .iter()
165            .filter_map(|(note, args)| args.map(|a| (*note, a)))
166            .collect()
167    }
168
169    /// Returns the expected output own notes of the transaction.
170    ///
171    /// In this context "own notes" refers to notes that are expected to be created directly by the
172    /// transaction script, rather than notes that are created as a result of consuming other
173    /// notes.
174    pub fn expected_output_own_notes(&self) -> Vec<Note> {
175        match &self.script_template {
176            Some(TransactionScriptTemplate::SendNotes(notes)) => notes
177                .iter()
178                .map(|partial| {
179                    Note::with_attachments(
180                        partial.assets().clone(),
181                        *partial.partial_metadata(),
182                        self.expected_output_recipients
183                            .get(&partial.recipient_digest())
184                            .expect("Recipient should be included if it's an own note")
185                            .clone(),
186                        partial.attachments().clone(),
187                    )
188                })
189                .collect(),
190            _ => vec![],
191        }
192    }
193
194    /// Returns an iterator over the expected output notes.
195    pub fn expected_output_recipients(&self) -> impl Iterator<Item = &NoteRecipient> {
196        self.expected_output_recipients.values()
197    }
198
199    /// Returns an iterator over expected future notes.
200    pub fn expected_future_notes(&self) -> impl Iterator<Item = &(NoteDetails, NoteTag)> {
201        self.expected_future_notes.values()
202    }
203
204    /// Returns the [`TransactionScriptTemplate`].
205    pub fn script_template(&self) -> &Option<TransactionScriptTemplate> {
206        &self.script_template
207    }
208
209    /// Returns the [`AdviceMap`] for the transaction request.
210    pub fn advice_map(&self) -> &AdviceMap {
211        &self.advice_map
212    }
213
214    /// Returns a mutable reference to the [`AdviceMap`] for the transaction request.
215    pub fn advice_map_mut(&mut self) -> &mut AdviceMap {
216        &mut self.advice_map
217    }
218
219    /// Returns the [`MerkleStore`] for the transaction request.
220    pub fn merkle_store(&self) -> &MerkleStore {
221        &self.merkle_store
222    }
223
224    /// Returns the required foreign accounts keyed by account ID.
225    pub fn foreign_accounts(&self) -> &BTreeMap<AccountId, ForeignAccount> {
226        &self.foreign_accounts
227    }
228
229    /// Returns whether to ignore invalid input notes or not.
230    pub fn ignore_invalid_input_notes(&self) -> bool {
231        self.ignore_invalid_input_notes
232    }
233
234    /// Returns the script argument for the transaction request.
235    pub fn script_arg(&self) -> &Option<Word> {
236        &self.script_arg
237    }
238
239    /// Returns the auth argument for the transaction request.
240    pub fn auth_arg(&self) -> &Option<Word> {
241        &self.auth_arg
242    }
243
244    /// Returns the caller-declared salt for the native fee conversion info the client commits
245    /// when preparing the transaction, set through
246    /// [`TransactionRequestBuilder::fee_conversion_salt`].
247    pub fn fee_conversion_salt(&self) -> Option<Word> {
248        self.fee_conversion_salt
249    }
250
251    /// Returns whether the request carries an auth arg that commits anything.
252    ///
253    /// An empty word counts as no arg: otherwise it would suppress the conversion info the client
254    /// attaches, leaving `fee::pay_fee` nothing to read.
255    pub fn has_auth_arg(&self) -> bool {
256        self.auth_arg.is_some_and(|auth_arg| auth_arg != Word::empty())
257    }
258
259    /// Returns the expected NTX scripts that the node's NTX builder will need in its registry.
260    pub fn expected_ntx_scripts(&self) -> &[NoteScript] {
261        &self.expected_ntx_scripts
262    }
263
264    // STATE MUTATORS
265    // --------------------------------------------------------------------------------------------
266
267    /// Adds `note` to the notes this request consumes, as an unauthenticated input.
268    ///
269    /// Building a request that consumes the note is the public way to do this. The harness needs
270    /// it on a request a test already built.
271    #[cfg(feature = "testing")]
272    pub(crate) fn add_unauthenticated_input_note(&mut self, note: Note) {
273        self.input_notes_args.push((note.id(), None));
274        self.input_notes.push(note);
275    }
276
277    /// Commits fee conversion info paying the fee in `fee_faucet_id`'s asset at rate 1/1 under
278    /// `salt`, through the auth args.
279    pub(crate) fn commit_native_fee_conversion_info(
280        &mut self,
281        fee_faucet_id: AccountId,
282        salt: Word,
283    ) {
284        let (auth_arg, preimage) =
285            commit_fee_conversion_info(FeeConversionInfo::one_to_one(fee_faucet_id), salt);
286        self.advice_map.insert(auth_arg, preimage);
287        self.auth_arg = Some(auth_arg);
288    }
289
290    /// Checks the invariants every request must hold, whether it was built or deserialized.
291    ///
292    /// # Errors
293    /// - If a note appears more than once among the input notes.
294    fn validate(&self) -> Result<(), TransactionRequestError> {
295        let mut seen_input_notes = BTreeSet::new();
296        for (note_id, _) in &self.input_notes_args {
297            if !seen_input_notes.insert(note_id) {
298                return Err(TransactionRequestError::DuplicateInputNote(*note_id));
299            }
300        }
301
302        Ok(())
303    }
304
305    /// Builds the [`InputNotes`] needed for the transaction execution.
306    ///
307    /// A note with a pinned mode keeps that mode. Any other note is authenticated when
308    /// `authenticated_note_records` holds a record for it and unauthenticated otherwise. The
309    /// result keeps the order of the request.
310    pub(crate) fn build_input_notes(
311        &self,
312        authenticated_note_records: Vec<InputNoteRecord>,
313    ) -> Result<InputNotes<InputNote>, TransactionRequestError> {
314        let mut authenticated_notes: BTreeMap<NoteId, InputNoteRecord> = BTreeMap::new();
315        for record in authenticated_note_records {
316            // Authenticated note records always carry metadata (their inclusion proof
317            // injected it), so `id()` is `Some`.
318            let note_id =
319                record.id().expect("authenticated note record carries metadata so id() is Some");
320
321            if !record.is_authenticated() {
322                return Err(TransactionRequestError::InputNoteNotAuthenticated(note_id));
323            }
324            if record.is_consumed() {
325                return Err(TransactionRequestError::InputNoteAlreadyConsumed(
326                    record.details_commitment(),
327                ));
328            }
329
330            authenticated_notes.insert(note_id, record);
331        }
332
333        let input_notes = self
334            .input_notes()
335            .iter()
336            .map(|note| match self.explicit_input_notes.get(&note.id()) {
337                Some(input_note) => input_note.clone(),
338                None => match authenticated_notes.remove(&note.id()) {
339                    Some(record) => record
340                        .try_into()
341                        .expect("Authenticated note record should be convertible to InputNote"),
342                    None => InputNote::unauthenticated(note.clone()),
343                },
344            })
345            .collect();
346
347        Ok(InputNotes::new(input_notes)?)
348    }
349
350    /// Converts the [`TransactionRequest`] into [`TransactionArgs`] in order to be executed by a
351    /// Miden host.
352    pub(crate) fn into_transaction_args(
353        self,
354        tx_script: Option<(TransactionScript, Option<Word>)>,
355    ) -> TransactionArgs {
356        let note_args = self.get_note_args();
357        let TransactionRequest {
358            expected_output_recipients,
359            advice_map,
360            merkle_store,
361            ..
362        } = self;
363
364        let mut tx_args = TransactionArgs::new(advice_map).with_note_args(note_args);
365
366        // A script argument without a script has nothing to bind to, so it is only applied when a
367        // transaction script is present. With no argument the default empty word is used, which is
368        // equivalent to setting no argument at all.
369        if let Some((tx_script, script_args)) = tx_script {
370            let script_args = script_args.or(self.script_arg).unwrap_or_default();
371            tx_args = tx_args.with_tx_script_and_args(tx_script, script_args);
372        }
373
374        if let Some(auth_argument) = self.auth_arg {
375            tx_args = tx_args.with_auth_args(auth_argument);
376        }
377
378        tx_args
379            .extend_output_note_recipients(expected_output_recipients.into_values().map(Box::new));
380        tx_args.extend_merkle_store(merkle_store.inner_nodes());
381
382        tx_args
383    }
384
385    /// Builds the transaction script based on the account capabilities and the transaction request.
386    ///
387    /// Returns the script together with the `TX_SCRIPT_ARGS` word it must be executed with, if the
388    /// script determines it. The `SendNotes` script reads the notes it creates from the advice
389    /// provider and only receives their payload commitment on the stack, so its argument
390    /// is fixed by the notes the script was built for, and passing anything else
391    /// makes the script fail to resolve its payload. A caller-supplied
392    /// [`TransactionScriptTemplate::CustomScript`] carries no such constraint and yields `None`, so
393    /// the request's own [`TransactionRequestBuilder::script_arg`] applies to it.
394    ///
395    /// Scripts supplied by the caller via [`TransactionScriptTemplate::CustomScript`] are expected
396    /// to have already been compiled against the client's source manager (e.g. via
397    /// [`Client::code_builder`](crate::Client::code_builder)).
398    pub(crate) fn build_transaction_script(
399        &self,
400        code_interface: &AccountCodeInterface,
401    ) -> Result<Option<(TransactionScript, Option<Word>)>, TransactionRequestError> {
402        match &self.script_template {
403            Some(TransactionScriptTemplate::CustomScript(script)) => {
404                Ok(Some((script.clone(), None)))
405            },
406            Some(TransactionScriptTemplate::SendNotes(notes)) => {
407                let script = match self.expiration_delta.and_then(NonZeroU16::new) {
408                    Some(delta) => SendNotesTransactionScript::with_expiration_delta(
409                        code_interface,
410                        notes,
411                        delta,
412                    )?,
413                    None => SendNotesTransactionScript::new(code_interface, notes)?,
414                };
415                Ok(Some((script.tx_script().clone(), Some(script.tx_script_args()))))
416            },
417            None => Ok(None),
418        }
419    }
420}
421
422// SERIALIZATION
423// ================================================================================================
424
425impl Serializable for TransactionRequest {
426    fn write_into<W: ByteWriter>(&self, target: &mut W) {
427        self.input_notes.write_into(target);
428        self.input_notes_args.write_into(target);
429        self.explicit_input_notes.write_into(target);
430        match &self.script_template {
431            None => target.write_u8(0),
432            Some(TransactionScriptTemplate::CustomScript(script)) => {
433                target.write_u8(1);
434                script.write_into(target);
435            },
436            Some(TransactionScriptTemplate::SendNotes(notes)) => {
437                target.write_u8(2);
438                notes.write_into(target);
439            },
440        }
441        self.expected_output_recipients.write_into(target);
442        self.expected_future_notes.write_into(target);
443        self.advice_map.write_into(target);
444        self.merkle_store.write_into(target);
445        let foreign_accounts: Vec<_> = self.foreign_accounts.values().cloned().collect();
446        foreign_accounts.write_into(target);
447        self.expiration_delta.write_into(target);
448        target.write_u8(u8::from(self.ignore_invalid_input_notes));
449        self.script_arg.write_into(target);
450        self.auth_arg.write_into(target);
451        self.fee_conversion_salt.write_into(target);
452        self.expected_ntx_scripts.write_into(target);
453    }
454}
455
456impl Deserializable for TransactionRequest {
457    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
458        let input_notes = Vec::<Note>::read_from(source)?;
459        let input_notes_args = Vec::<(NoteId, Option<NoteArgs>)>::read_from(source)?;
460        let explicit_input_notes = BTreeMap::<NoteId, InputNote>::read_from(source)?;
461        for (note_id, input_note) in &explicit_input_notes {
462            if *note_id != input_note.id() || !input_notes.contains(input_note.note()) {
463                return Err(DeserializationError::InvalidValue(format!(
464                    "explicit input note {note_id} does not match a request input note"
465                )));
466            }
467        }
468
469        let script_template = match source.read_u8()? {
470            0 => None,
471            1 => {
472                let transaction_script = TransactionScript::read_from(source)?;
473                Some(TransactionScriptTemplate::CustomScript(transaction_script))
474            },
475            2 => {
476                let notes = Vec::<PartialNote>::read_from(source)?;
477                Some(TransactionScriptTemplate::SendNotes(notes))
478            },
479            _ => {
480                return Err(DeserializationError::InvalidValue(
481                    "Invalid script template type".to_string(),
482                ));
483            },
484        };
485
486        let expected_output_recipients = BTreeMap::<Word, NoteRecipient>::read_from(source)?;
487        let expected_future_notes =
488            BTreeMap::<NoteDetailsCommitment, (NoteDetails, NoteTag)>::read_from(source)?;
489
490        let advice_map = AdviceMap::read_from(source)?;
491        let merkle_store = MerkleStore::read_from(source)?;
492        let mut foreign_accounts = BTreeMap::new();
493        for foreign_account in Vec::<ForeignAccount>::read_from(source)? {
494            foreign_accounts.entry(foreign_account.account_id()).or_insert(foreign_account);
495        }
496        let expiration_delta = Option::<u16>::read_from(source)?;
497        let ignore_invalid_input_notes = source.read_u8()? == 1;
498        let script_arg = Option::<Word>::read_from(source)?;
499        let auth_arg = Option::<Word>::read_from(source)?;
500        let fee_conversion_salt = Option::<Word>::read_from(source)?;
501        let expected_ntx_scripts = Vec::<NoteScript>::read_from(source)?;
502
503        let request = TransactionRequest {
504            input_notes,
505            input_notes_args,
506            explicit_input_notes,
507            script_template,
508            expected_output_recipients,
509            expected_future_notes,
510            advice_map,
511            merkle_store,
512            foreign_accounts,
513            expiration_delta,
514            ignore_invalid_input_notes,
515            script_arg,
516            auth_arg,
517            fee_conversion_salt,
518            expected_ntx_scripts,
519        };
520        request
521            .validate()
522            .map_err(|err| DeserializationError::InvalidValue(err.to_string()))?;
523
524        Ok(request)
525    }
526}
527
528// HELPERS
529// ================================================================================================
530
531/// Accumulates fungible totals and collectable non-fungible assets from an iterator of assets.
532pub(crate) fn collect_assets<'a>(
533    assets: impl Iterator<Item = &'a Asset>,
534) -> (BTreeMap<AccountId, u64>, Vec<NonFungibleAsset>) {
535    let mut fungible_balance_map = BTreeMap::new();
536    let mut non_fungible_set = Vec::new();
537
538    assets.for_each(|asset| match asset {
539        Asset::Fungible(fungible) => {
540            let amount = fungible.amount().as_u64();
541            fungible_balance_map
542                .entry(fungible.faucet_id())
543                .and_modify(|balance| *balance += amount)
544                .or_insert(amount);
545        },
546        Asset::NonFungible(non_fungible) => {
547            if !non_fungible_set.contains(non_fungible) {
548                non_fungible_set.push(*non_fungible);
549            }
550        },
551    });
552
553    (fungible_balance_map, non_fungible_set)
554}
555
556impl Default for TransactionRequestBuilder {
557    fn default() -> Self {
558        Self::new()
559    }
560}
561
562// TRANSACTION REQUEST ERROR
563// ================================================================================================
564
565// Errors related to a [TransactionRequest]
566#[derive(Debug, Error)]
567pub enum TransactionRequestError {
568    #[error("failed to build the send-notes transaction script")]
569    SendNotesTransactionScriptError(#[from] SendNotesTransactionScriptError),
570    #[error("account error")]
571    AccountError(#[from] AccountError),
572    #[error("asset error")]
573    AssetError(#[from] AssetError),
574    #[error("duplicate input note: note {0} was added more than once to the transaction")]
575    DuplicateInputNote(NoteId),
576    #[error("transaction expiration delta must be greater than zero")]
577    ZeroExpirationDelta,
578    #[error(
579        "the account proof does not contain the required foreign account data; re-fetch the proof and retry"
580    )]
581    ForeignAccountDataMissing,
582    #[error(
583        "foreign account {0} has incompatible visibility; use `ForeignAccount::public()` for public accounts and `ForeignAccount::private()` for private accounts"
584    )]
585    InvalidForeignAccountId(AccountId),
586    #[error(
587        "inputs for foreign account {account_id} do not open against the account tree of the \
588         transaction's reference block {block_num}"
589    )]
590    ForeignAccountNotAtReferenceBlock {
591        account_id: AccountId,
592        block_num: BlockNumber,
593    },
594    #[error(
595        "note {0} cannot be used as an authenticated input: it does not have a valid inclusion proof"
596    )]
597    InputNoteNotAuthenticated(NoteId),
598    #[error("note with details commitment {} has already been consumed", .0.to_hex())]
599    InputNoteAlreadyConsumed(NoteDetailsCommitment),
600    #[error(
601        "output note declares sender {actual} but the transaction is executed by account {expected}"
602    )]
603    OutputNoteSenderMismatch { expected: AccountId, actual: AccountId },
604    #[error(
605        "the request declares a fee conversion salt but the account's auth component {0} does not \
606         read the auth args as fee conversion info"
607    )]
608    FeeConversionInfoUnsupported(String),
609    #[error(
610        "account's `{0}` component reuses the fee conversion salt as a replay guard, so the \
611         caller must declare a fresh one with `TransactionRequestBuilder::fee_conversion_salt`"
612    )]
613    FeeConversionInfoRequired(String),
614    #[error("invalid transaction script")]
615    InvalidTransactionScript(#[from] TransactionScriptError),
616    #[error("merkle proof error")]
617    MerkleError(#[from] MerkleError),
618    #[error("empty transaction: the request has no input notes and no account state changes")]
619    NoInputNotesNorAccountChange,
620    #[error("note not found: {0}")]
621    NoteNotFound(String),
622    #[error("failed to create note")]
623    NoteCreationError(#[from] NoteError),
624    #[error("note failed validation")]
625    NoteValidationError(#[source] NoteError),
626    #[error("note execution failed")]
627    NoteExecutionError(#[source] NoteError),
628    #[error("failed to build note args")]
629    NoteArgError(#[source] NoteError),
630    #[error("pay-to-ID note must contain at least one asset to transfer")]
631    P2IDNoteWithoutAsset,
632    #[error(
633        "non-fungible asset issued by faucet {0} is not available in the account vault or incoming notes"
634    )]
635    MissingNonFungibleAsset(AccountId),
636    #[error("PSWAP note can only be cancelled by its creator: expected {expected}, got {actual}")]
637    PswapCancelCreatorMismatch { expected: AccountId, actual: AccountId },
638    #[error("error building script")]
639    CodeBuilderError(#[from] CodeBuilderError),
640    #[error("transaction script template error: {0}")]
641    ScriptTemplateError(String),
642    #[error("foreign procedure takes at most {max} input felts, got {actual}")]
643    ForeignProcedureInputsTooLong { max: usize, actual: usize },
644    #[error("storage slot {0} not found in account ID {1}")]
645    StorageSlotNotFound(u8, AccountId),
646    #[error("error while building the input notes")]
647    TransactionInputError(#[from] TransactionInputError),
648    #[error("account storage map error")]
649    StorageMapError(#[from] StorageMapError),
650    #[error("asset vault error")]
651    AssetVaultError(#[from] AssetVaultError),
652    #[error(
653        "unsupported authentication scheme ID {0}; supported schemes are: RpoFalcon512 (0) and EcdsaK256Keccak (1)"
654    )]
655    UnsupportedAuthSchemeId(u8),
656}
657
658// TESTS
659// ================================================================================================
660
661#[cfg(test)]
662mod tests {
663    use std::vec::Vec;
664
665    use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment};
666    use miden_protocol::account::{
667        AccountBuilder,
668        AccountComponent,
669        AccountId,
670        AccountType,
671        StorageMapKey,
672        StorageSlotName,
673    };
674    use miden_protocol::asset::FungibleAsset;
675    use miden_protocol::block::account_tree::AccountTree;
676    use miden_protocol::crypto::rand::{FeltRng, RandomCoin};
677    use miden_protocol::note::{NoteTag, NoteType};
678    use miden_protocol::testing::account_id::{
679        ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
680        ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
681        ACCOUNT_ID_SENDER,
682    };
683    use miden_protocol::transaction::{AccountInputs, InputNote};
684    use miden_protocol::{EMPTY_WORD, Felt, Word};
685    use miden_standards::account::auth::{Approver, AuthSingleSig};
686    use miden_standards::note::P2idNote;
687    use miden_standards::testing::account_component::MockAccountComponent;
688    use miden_tx::utils::serde::{Deserializable, Serializable};
689
690    use super::{TransactionRequest, TransactionRequestBuilder};
691    use crate::rpc::domain::account::AccountStorageRequirements;
692    use crate::transaction::ForeignAccount;
693
694    #[test]
695    fn transaction_request_serialization() {
696        assert_transaction_request_serialization_with(|| {
697            AuthSingleSig::new(Approver::new(
698                PublicKeyCommitment::from(EMPTY_WORD),
699                AuthScheme::Falcon512Poseidon2,
700            ))
701            .into()
702        });
703    }
704
705    #[test]
706    fn transaction_request_serialization_ecdsa() {
707        assert_transaction_request_serialization_with(|| {
708            AuthSingleSig::new(Approver::new(
709                PublicKeyCommitment::from(EMPTY_WORD),
710                AuthScheme::EcdsaK256Keccak,
711            ))
712            .into()
713        });
714    }
715
716    #[test]
717    fn deserialization_rejects_duplicate_input_notes() {
718        let sender_id = AccountId::try_from(ACCOUNT_ID_SENDER).unwrap();
719        let target_id =
720            AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();
721        let faucet_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap();
722        let note = P2idNote::builder()
723            .sender(sender_id)
724            .target(target_id)
725            .assets(vec![FungibleAsset::new(faucet_id, 100).unwrap()])
726            .note_type(NoteType::Private)
727            .generate_serial_number(&mut RandomCoin::new(Word::default()))
728            .build()
729            .unwrap();
730
731        // The builder rejects a duplicate, so the built request is corrupted by hand.
732        let mut tx_request = TransactionRequestBuilder::new()
733            .input_notes(vec![(note.into(), None)])
734            .build()
735            .unwrap();
736        let note_id = tx_request.input_note_ids().next().unwrap();
737        tx_request.input_notes.push(tx_request.input_notes[0].clone());
738        tx_request.input_notes_args.push((note_id, None));
739
740        assert!(TransactionRequest::read_from_bytes(&tx_request.to_bytes()).is_err());
741    }
742
743    fn assert_transaction_request_serialization_with<F>(auth_component: F)
744    where
745        F: FnOnce() -> AccountComponent,
746    {
747        let sender_id = AccountId::try_from(ACCOUNT_ID_SENDER).unwrap();
748        let target_id =
749            AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();
750        let faucet_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap();
751        let mut rng = RandomCoin::new(Word::default());
752
753        let mut notes = vec![];
754        for i in 0..7 {
755            let note = P2idNote::builder()
756                .sender(sender_id)
757                .target(target_id)
758                .assets(vec![FungibleAsset::new(faucet_id, 100 + i).unwrap()])
759                .note_type(NoteType::Private)
760                .generate_serial_number(&mut rng)
761                .build()
762                .expect("note creation failed");
763            notes.push(note.into());
764        }
765
766        let mut advice_vec: Vec<(Word, Vec<Felt>)> = vec![];
767        for i in 0u32..10 {
768            advice_vec.push((rng.draw_word(), vec![Felt::from(i)]));
769        }
770
771        let account = AccountBuilder::new(Default::default())
772            .with_component(MockAccountComponent::with_empty_slots())
773            .with_component(auth_component())
774            .account_type(AccountType::Private)
775            .build_existing()
776            .unwrap();
777
778        // This transaction request wouldn't be valid in a real scenario, it's intended for testing
779        let tx_request = TransactionRequestBuilder::new()
780            .input_notes(vec![(notes.pop().unwrap(), None)])
781            .explicit_input_notes(vec![(
782                InputNote::unauthenticated(notes.pop().unwrap()),
783                Some(rng.draw_word()),
784            )])
785            .expected_output_recipients(vec![notes.pop().unwrap().recipient().clone()])
786            .expected_future_notes(vec![(
787                notes.pop().unwrap().into(),
788                NoteTag::with_account_target(sender_id),
789            )])
790            .extend_advice_map(advice_vec)
791            .foreign_accounts([
792                ForeignAccount::public(
793                    target_id,
794                    AccountStorageRequirements::new([(
795                        StorageSlotName::new("demo::storage_slot").unwrap(),
796                        &[StorageMapKey::new(Word::default())],
797                    )]),
798                )
799                .unwrap(),
800                ForeignAccount::private(&account).unwrap(),
801            ])
802            .own_output_notes(vec![notes.pop().unwrap(), notes.pop().unwrap()])
803            .script_arg(rng.draw_word())
804            .auth_arg(rng.draw_word())
805            .expected_ntx_scripts(vec![notes.first().unwrap().recipient().script().clone()])
806            .build()
807            .unwrap();
808
809        let mut buffer = Vec::new();
810        tx_request.write_into(&mut buffer);
811
812        let deserialized_tx_request = TransactionRequest::read_from_bytes(&buffer).unwrap();
813        assert_eq!(tx_request, deserialized_tx_request);
814
815        let tree = AccountTree::with_entries([(account.id(), account.to_commitment())]).unwrap();
816        let inputs = AccountInputs::new((&account).into(), tree.open(account.id()));
817        let mut request = tx_request;
818        request.foreign_accounts.insert(inputs.id(), ForeignAccount::Prefetched(inputs));
819        let decoded = TransactionRequest::read_from_bytes(&request.to_bytes()).unwrap();
820        assert_eq!(request, decoded);
821    }
822}