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