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