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