Skip to main content

miden_standards/tx_script/
send_notes_script.rs

1use alloc::vec::Vec;
2use core::num::NonZeroU16;
3
4use miden_protocol::account::{AccountCodeInterface, AccountId, AccountProcedureRoot};
5use miden_protocol::asset::AssetComposition;
6use miden_protocol::note::PartialNote;
7use miden_protocol::transaction::{TransactionScript, TransactionScriptRoot};
8use miden_protocol::utils::sync::LazyLock;
9use miden_protocol::vm::AdviceMap;
10use miden_protocol::{Felt, Hasher, Word, ZERO};
11use thiserror::Error;
12
13use crate::account::access::{Ownable2Step, RoleBasedAccessControl};
14use crate::account::faucets::{FungibleFaucet, NonFungibleFaucet};
15use crate::account::wallets::BasicWallet;
16use crate::tx_script::transaction_script;
17
18// CONSTANTS
19// ================================================================================================
20
21/// Path to the `send_notes` wallet transaction script procedure in the standards library.
22const SEND_NOTES_WALLET_TX_SCRIPT_PATH: &str =
23    "::miden::standards::tx_scripts::send_notes::wallet::main";
24
25/// Path to the `send_notes` fungible faucet transaction script procedure in the standards library.
26const SEND_NOTES_FUNGIBLE_FAUCET_TX_SCRIPT_PATH: &str =
27    "::miden::standards::tx_scripts::send_notes::fungible_faucet::main";
28
29/// Path to the `send_notes` non-fungible faucet transaction script procedure in the standards
30/// library.
31const SEND_NOTES_NON_FUNGIBLE_FAUCET_TX_SCRIPT_PATH: &str =
32    "::miden::standards::tx_scripts::send_notes::non_fungible_faucet::main";
33
34// SEND NOTES TRANSACTION SCRIPT
35// ================================================================================================
36
37static SEND_NOTES_WALLET_TX_SCRIPT: LazyLock<TransactionScript> =
38    LazyLock::new(|| transaction_script(SEND_NOTES_WALLET_TX_SCRIPT_PATH));
39
40static SEND_NOTES_FUNGIBLE_FAUCET_TX_SCRIPT: LazyLock<TransactionScript> =
41    LazyLock::new(|| transaction_script(SEND_NOTES_FUNGIBLE_FAUCET_TX_SCRIPT_PATH));
42
43static SEND_NOTES_NON_FUNGIBLE_FAUCET_TX_SCRIPT: LazyLock<TransactionScript> =
44    LazyLock::new(|| transaction_script(SEND_NOTES_NON_FUNGIBLE_FAUCET_TX_SCRIPT_PATH));
45
46/// A `send_notes` [`TransactionScript`] for an account, abstracting over the concrete script that
47/// the account's code interface calls for.
48///
49/// Each variant wraps the dedicated type for one canonical script. Use [`Self::new`] to let the
50/// account's interface decide, or construct a concrete type directly when the kind is known.
51///
52/// The script is picked from the interfaces the account exposes, cross-checked against the
53/// composition of the assets being sent: a faucet script is built only for notes its faucet can
54/// mint, and any other notes are sent with the [`BasicWallet`] script.
55///
56/// # Example
57///
58/// ```ignore
59/// let script = SendNotesTransactionScript::new(&interface, &notes)?;
60///
61/// let tx_args = TransactionArgs::new(AdviceMap::default())
62///     .with_tx_script_and_args(script.tx_script().clone(), script.tx_script_args());
63/// ```
64#[derive(Debug, Clone)]
65#[non_exhaustive]
66pub enum SendNotesTransactionScript {
67    /// Sends notes holding assets the account already owns.
68    Wallet(SendWalletNotesTransactionScript),
69    /// Sends notes holding assets the fungible faucet mints as part of note creation.
70    Fungible(SendFungibleFaucetNotesTransactionScript),
71    /// Sends notes holding assets the non-fungible faucet mints as part of note creation.
72    NonFungible(SendNonFungibleFaucetNotesTransactionScript),
73}
74
75impl SendNotesTransactionScript {
76    // CONSTANTS
77    // --------------------------------------------------------------------------------------------
78
79    /// Number of elements in the payload header word: `[num_notes, expiration_delta, 0, 0]`.
80    ///
81    /// See `encode_payload` for the full payload layout.
82    pub const PAYLOAD_HEADER_NUM_ELEMENTS: usize = 4;
83
84    /// Element offset of the asset count within a note record, after the RECIPIENT word and the
85    /// `tag` and `note_type` elements.
86    ///
87    /// See `encode_payload` for the full payload layout.
88    pub const NOTE_RECORD_NUM_ASSETS_OFFSET: usize = 6;
89
90    /// Element offset of the first asset or attachment item within a note record, after the
91    /// RECIPIENT word and the metadata word.
92    ///
93    /// See `encode_payload` for the full payload layout.
94    pub const NOTE_RECORD_ITEMS_OFFSET: usize = 8;
95
96    /// Number of elements a single asset or attachment item occupies (two words).
97    ///
98    /// See `encode_payload` for the full payload layout.
99    pub const ITEM_NUM_ELEMENTS: usize = 8;
100
101    // CONSTRUCTORS
102    // --------------------------------------------------------------------------------------------
103
104    /// Builds the `send_notes` script that the account described by `interface` calls for, without
105    /// an expiration delta.
106    ///
107    /// See [`Self::with_expiration_delta`] for the variant that pins the transaction to a
108    /// reference-block delta.
109    ///
110    /// # Errors
111    ///
112    /// Returns an error if the interface exposes none of the wallet, fungible faucet or
113    /// non-fungible faucet procedures, or if the notes fail the selected script's validation.
114    pub fn new(
115        interface: &AccountCodeInterface,
116        output_notes: &[PartialNote],
117    ) -> Result<Self, SendNotesTransactionScriptError> {
118        Self::build(interface, output_notes, 0)
119    }
120
121    /// Builds the `send_notes` script that the account described by `interface` calls for, with the
122    /// given non-zero expiration delta.
123    ///
124    /// The delta specifies how close to the transaction's reference block the transaction must be
125    /// included into the chain. For example, with a reference block of 100 and a delta of 10, the
126    /// transaction must be included by block 110.
127    ///
128    /// # Errors
129    ///
130    /// See [`Self::new`].
131    pub fn with_expiration_delta(
132        interface: &AccountCodeInterface,
133        output_notes: &[PartialNote],
134        expiration_delta: NonZeroU16,
135    ) -> Result<Self, SendNotesTransactionScriptError> {
136        Self::build(interface, output_notes, expiration_delta.get())
137    }
138
139    // PUBLIC ACCESSORS
140    // --------------------------------------------------------------------------------------------
141
142    /// The underlying [`TransactionScript`], to be set as the transaction's script.
143    pub fn tx_script(&self) -> &TransactionScript {
144        match self {
145            Self::Wallet(script) => script.tx_script(),
146            Self::Fungible(script) => script.tx_script(),
147            Self::NonFungible(script) => script.tx_script(),
148        }
149    }
150
151    /// The transaction script argument the script reads its payload commitment from.
152    ///
153    /// Pass this as the transaction's `TX_SCRIPT_ARGS`.
154    pub fn tx_script_args(&self) -> Word {
155        match self {
156            Self::Wallet(script) => script.tx_script_args(),
157            Self::Fungible(script) => script.tx_script_args(),
158            Self::NonFungible(script) => script.tx_script_args(),
159        }
160    }
161
162    /// The [`TransactionScriptRoot`]s of every canonical `send_notes` script.
163    ///
164    /// Allowlisting all of them covers any account this type can build a script for.
165    pub fn script_roots() -> [TransactionScriptRoot; 3] {
166        [
167            SendWalletNotesTransactionScript::script_root(),
168            SendFungibleFaucetNotesTransactionScript::script_root(),
169            SendNonFungibleFaucetNotesTransactionScript::script_root(),
170        ]
171    }
172
173    // HELPER FUNCTIONS
174    // --------------------------------------------------------------------------------------------
175
176    fn build(
177        interface: &AccountCodeInterface,
178        output_notes: &[PartialNote],
179        expiration_delta: u16,
180    ) -> Result<Self, SendNotesTransactionScriptError> {
181        let fungible_faucet = interface.contains([FungibleFaucet::mint_and_send_root()]);
182        let non_fungible_faucet = interface.contains([NonFungibleFaucet::mint_and_send_root()]);
183        let basic_wallet = interface
184            .contains([BasicWallet::move_asset_to_note_root(), BasicWallet::create_note_root()]);
185
186        // A faucet script mints one asset per note as part of note creation, so it applies only to
187        // notes shaped that way whose assets all have the composition that faucet mints. Notes the
188        // faucet cannot mint hold assets the account already owns, which the wallet script sends.
189        let one_asset_per_note = output_notes.iter().all(|note| note.assets().num_assets() == 1);
190        let mints_composition = |composition| {
191            one_asset_per_note
192                && output_notes
193                    .iter()
194                    .flat_map(|note| note.assets().iter())
195                    .all(|asset| asset.id().composition() == composition)
196        };
197
198        if fungible_faucet && mints_composition(AssetComposition::Fungible) {
199            SendFungibleFaucetNotesTransactionScript::build(
200                interface,
201                output_notes,
202                expiration_delta,
203            )
204            .map(Self::Fungible)
205        } else if non_fungible_faucet && mints_composition(AssetComposition::None) {
206            SendNonFungibleFaucetNotesTransactionScript::build(
207                interface,
208                output_notes,
209                expiration_delta,
210            )
211            .map(Self::NonFungible)
212        } else if basic_wallet {
213            SendWalletNotesTransactionScript::build(interface, output_notes, expiration_delta)
214                .map(Self::Wallet)
215        } else if !(fungible_faucet || non_fungible_faucet) {
216            Err(SendNotesTransactionScriptError::UnsupportedAccountInterface)
217        } else if !one_asset_per_note {
218            Err(SendNotesTransactionScriptError::FaucetNoteUnexpectedNumAssets)
219        } else {
220            // The notes are shaped for a faucet, but carry an asset composition this one does not
221            // mint.
222            let expected = if fungible_faucet {
223                AssetComposition::Fungible
224            } else {
225                AssetComposition::None
226            };
227            let actual = output_notes
228                .iter()
229                .flat_map(|note| note.assets().iter())
230                .map(|asset| asset.id().composition())
231                .find(|composition| *composition != expected)
232                .unwrap_or(expected);
233
234            Err(SendNotesTransactionScriptError::AssetCompositionMismatch { expected, actual })
235        }
236    }
237}
238
239// SEND WALLET NOTES TRANSACTION SCRIPT
240// ================================================================================================
241
242/// The canonical `send_notes` [`TransactionScript`] for accounts exposing the [`BasicWallet`]
243/// procedures, which sends notes holding assets the account already owns.
244///
245/// The payload and the attachment contents the script reads from the advice provider are embedded
246/// in the script's MAST forest, so they are loaded with the script. Callers only have to set the
247/// script ([`Self::tx_script`]) and the payload commitment it reads its parameters from
248/// ([`Self::tx_script_args`]).
249#[derive(Debug, Clone)]
250pub struct SendWalletNotesTransactionScript(SendNotesScript);
251
252impl SendWalletNotesTransactionScript {
253    /// Builds the script for the account described by `interface`, without an expiration delta.
254    ///
255    /// # Errors
256    ///
257    /// See [`Self::with_expiration_delta`].
258    pub fn new(
259        interface: &AccountCodeInterface,
260        output_notes: &[PartialNote],
261    ) -> Result<Self, SendNotesTransactionScriptError> {
262        Self::build(interface, output_notes, 0)
263    }
264
265    /// Builds the script for the account described by `interface`, with the given non-zero
266    /// expiration delta.
267    ///
268    /// # Errors
269    ///
270    /// Returns an error if:
271    /// - The interface does not expose the [`BasicWallet`] procedures the script calls.
272    /// - Any note is not sent by the account.
273    pub fn with_expiration_delta(
274        interface: &AccountCodeInterface,
275        output_notes: &[PartialNote],
276        expiration_delta: NonZeroU16,
277    ) -> Result<Self, SendNotesTransactionScriptError> {
278        Self::build(interface, output_notes, expiration_delta.get())
279    }
280
281    /// The underlying [`TransactionScript`], to be set as the transaction's script.
282    pub fn tx_script(&self) -> &TransactionScript {
283        self.0.tx_script()
284    }
285
286    /// The transaction script argument the script reads its payload commitment from.
287    pub fn tx_script_args(&self) -> Word {
288        self.0.tx_script_args()
289    }
290
291    /// The [`TransactionScriptRoot`] of the canonical wallet script.
292    pub fn script_root() -> TransactionScriptRoot {
293        SEND_NOTES_WALLET_TX_SCRIPT.root()
294    }
295
296    fn build(
297        interface: &AccountCodeInterface,
298        output_notes: &[PartialNote],
299        expiration_delta: u16,
300    ) -> Result<Self, SendNotesTransactionScriptError> {
301        // The script calls both procedures, so both must be exposed.
302        let can_send_own_assets = interface
303            .contains([BasicWallet::move_asset_to_note_root(), BasicWallet::create_note_root()]);
304        if !can_send_own_assets {
305            return Err(SendNotesTransactionScriptError::UnsupportedAccountInterface);
306        }
307
308        for note in output_notes {
309            validate_note_sender(interface.id(), note)?;
310        }
311
312        Ok(Self(SendNotesScript::new(
313            SEND_NOTES_WALLET_TX_SCRIPT.clone(),
314            output_notes,
315            expiration_delta,
316        )))
317    }
318}
319
320// SEND FUNGIBLE FAUCET NOTES TRANSACTION SCRIPT
321// ================================================================================================
322
323/// The canonical `send_notes` [`TransactionScript`] for accounts exposing the [`FungibleFaucet`]
324/// procedures, which sends notes holding assets the faucet mints as part of note creation.
325///
326/// Every note must carry exactly one fungible asset, issued by this faucet.
327///
328/// Faucets that delegate minting to an authority (those exposing [`Ownable2Step`] or
329/// [`RoleBasedAccessControl`]) are network faucets that mint exclusively via MINT notes, so they
330/// are rejected at script build time to avoid runtime failures under their OwnerOnly mint policy.
331///
332/// The payload and the attachment contents the script reads from the advice provider are embedded
333/// in the script's MAST forest, so they are loaded with the script. Callers only have to set the
334/// script ([`Self::tx_script`]) and the payload commitment it reads its parameters from
335/// ([`Self::tx_script_args`]).
336#[derive(Debug, Clone)]
337pub struct SendFungibleFaucetNotesTransactionScript(SendNotesScript);
338
339impl SendFungibleFaucetNotesTransactionScript {
340    /// Builds the script for the faucet described by `interface`, without an expiration delta.
341    ///
342    /// # Errors
343    ///
344    /// See [`Self::with_expiration_delta`].
345    pub fn new(
346        interface: &AccountCodeInterface,
347        output_notes: &[PartialNote],
348    ) -> Result<Self, SendNotesTransactionScriptError> {
349        Self::build(interface, output_notes, 0)
350    }
351
352    /// Builds the script for the faucet described by `interface`, with the given non-zero
353    /// expiration delta.
354    ///
355    /// # Errors
356    ///
357    /// Returns an error if:
358    /// - The interface does not expose the [`FungibleFaucet`] procedure the script calls.
359    /// - The faucet delegates minting to an authority.
360    /// - Any note is not sent by the faucet.
361    /// - Any note does not carry exactly one fungible asset issued by the faucet.
362    pub fn with_expiration_delta(
363        interface: &AccountCodeInterface,
364        output_notes: &[PartialNote],
365        expiration_delta: NonZeroU16,
366    ) -> Result<Self, SendNotesTransactionScriptError> {
367        Self::build(interface, output_notes, expiration_delta.get())
368    }
369
370    /// The underlying [`TransactionScript`], to be set as the transaction's script.
371    pub fn tx_script(&self) -> &TransactionScript {
372        self.0.tx_script()
373    }
374
375    /// The transaction script argument the script reads its payload commitment from.
376    pub fn tx_script_args(&self) -> Word {
377        self.0.tx_script_args()
378    }
379
380    /// The [`TransactionScriptRoot`] of the canonical fungible faucet script.
381    pub fn script_root() -> TransactionScriptRoot {
382        SEND_NOTES_FUNGIBLE_FAUCET_TX_SCRIPT.root()
383    }
384
385    fn build(
386        interface: &AccountCodeInterface,
387        output_notes: &[PartialNote],
388        expiration_delta: u16,
389    ) -> Result<Self, SendNotesTransactionScriptError> {
390        validate_faucet(interface, FungibleFaucet::mint_and_send_root())?;
391        validate_minted_notes(interface.id(), output_notes, AssetComposition::Fungible)?;
392
393        Ok(Self(SendNotesScript::new(
394            SEND_NOTES_FUNGIBLE_FAUCET_TX_SCRIPT.clone(),
395            output_notes,
396            expiration_delta,
397        )))
398    }
399}
400
401// SEND NON-FUNGIBLE FAUCET NOTES TRANSACTION SCRIPT
402// ================================================================================================
403
404/// The canonical `send_notes` [`TransactionScript`] for accounts exposing the [`NonFungibleFaucet`]
405/// procedures, which sends notes holding assets the faucet mints as part of note creation.
406///
407/// Every note must carry exactly one non-fungible asset, issued by this faucet. Unlike the fungible
408/// faucet, `non_fungible::mint_and_send` derives the asset from the faucet itself, so the script
409/// passes it only the asset's commitment.
410///
411/// Faucets that delegate minting to an authority (those exposing [`Ownable2Step`] or
412/// [`RoleBasedAccessControl`]) are network faucets that mint exclusively via MINT notes, so they
413/// are rejected at script build time to avoid runtime failures under their OwnerOnly mint policy.
414///
415/// The payload and the attachment contents the script reads from the advice provider are embedded
416/// in the script's MAST forest, so they are loaded with the script. Callers only have to set the
417/// script ([`Self::tx_script`]) and the payload commitment it reads its parameters from
418/// ([`Self::tx_script_args`]).
419#[derive(Debug, Clone)]
420pub struct SendNonFungibleFaucetNotesTransactionScript(SendNotesScript);
421
422impl SendNonFungibleFaucetNotesTransactionScript {
423    /// Builds the script for the faucet described by `interface`, without an expiration delta.
424    ///
425    /// # Errors
426    ///
427    /// See [`Self::with_expiration_delta`].
428    pub fn new(
429        interface: &AccountCodeInterface,
430        output_notes: &[PartialNote],
431    ) -> Result<Self, SendNotesTransactionScriptError> {
432        Self::build(interface, output_notes, 0)
433    }
434
435    /// Builds the script for the faucet described by `interface`, with the given non-zero
436    /// expiration delta.
437    ///
438    /// # Errors
439    ///
440    /// Returns an error if:
441    /// - The interface does not expose the [`NonFungibleFaucet`] procedure the script calls.
442    /// - The faucet delegates minting to an authority.
443    /// - Any note is not sent by the faucet.
444    /// - Any note does not carry exactly one non-fungible asset issued by the faucet.
445    pub fn with_expiration_delta(
446        interface: &AccountCodeInterface,
447        output_notes: &[PartialNote],
448        expiration_delta: NonZeroU16,
449    ) -> Result<Self, SendNotesTransactionScriptError> {
450        Self::build(interface, output_notes, expiration_delta.get())
451    }
452
453    /// The underlying [`TransactionScript`], to be set as the transaction's script.
454    pub fn tx_script(&self) -> &TransactionScript {
455        self.0.tx_script()
456    }
457
458    /// The transaction script argument the script reads its payload commitment from.
459    pub fn tx_script_args(&self) -> Word {
460        self.0.tx_script_args()
461    }
462
463    /// The [`TransactionScriptRoot`] of the canonical non-fungible faucet script.
464    pub fn script_root() -> TransactionScriptRoot {
465        SEND_NOTES_NON_FUNGIBLE_FAUCET_TX_SCRIPT.root()
466    }
467
468    fn build(
469        interface: &AccountCodeInterface,
470        output_notes: &[PartialNote],
471        expiration_delta: u16,
472    ) -> Result<Self, SendNotesTransactionScriptError> {
473        validate_faucet(interface, NonFungibleFaucet::mint_and_send_root())?;
474        validate_minted_notes(interface.id(), output_notes, AssetComposition::None)?;
475
476        Ok(Self(SendNotesScript::new(
477            SEND_NOTES_NON_FUNGIBLE_FAUCET_TX_SCRIPT.clone(),
478            output_notes,
479            expiration_delta,
480        )))
481    }
482}
483
484// SEND NOTES SCRIPT
485// ================================================================================================
486
487/// The state every `send_notes` script shares: the script itself, with the data it reads from the
488/// advice provider embedded in its MAST forest, and the commitment to that data.
489#[derive(Debug, Clone)]
490struct SendNotesScript {
491    script: TransactionScript,
492    tx_script_args: Word,
493}
494
495impl SendNotesScript {
496    /// Encodes `output_notes` into the payload the `send_notes` scripts expect and embeds it, along
497    /// with every attachment's contents, into `script`'s MAST forest.
498    fn new(script: TransactionScript, output_notes: &[PartialNote], expiration_delta: u16) -> Self {
499        let payload = encode_payload(output_notes, expiration_delta);
500        let tx_script_args = Hasher::hash_elements(&payload);
501
502        // Embed the data the script reads from the advice provider into the script's MAST forest,
503        // so it is loaded automatically and callers only have to set the script and its arguments.
504        let mut advice_map = AdviceMap::default();
505        advice_map.insert(tx_script_args, payload);
506        for note in output_notes {
507            for attachment in note.attachments().iter() {
508                advice_map.insert(attachment.to_commitment(), attachment.to_elements());
509            }
510        }
511
512        Self {
513            script: script.with_advice_map(advice_map),
514            tx_script_args,
515        }
516    }
517
518    fn tx_script(&self) -> &TransactionScript {
519        &self.script
520    }
521
522    fn tx_script_args(&self) -> Word {
523        self.tx_script_args
524    }
525}
526
527// SEND NOTES SCRIPT ERROR
528// ================================================================================================
529
530/// Errors that can occur while building a [`SendNotesTransactionScript`].
531#[derive(Debug, Error)]
532pub enum SendNotesTransactionScriptError {
533    #[error("note asset is not issued by faucet {0}")]
534    IssuanceFaucetMismatch(AccountId),
535    #[error("note created by the faucet doesn't contain exactly one asset")]
536    FaucetNoteUnexpectedNumAssets,
537    #[error(
538        "note asset has the {actual} composition but the faucet mints assets with the {expected} \
539         composition"
540    )]
541    AssetCompositionMismatch {
542        expected: AssetComposition,
543        actual: AssetComposition,
544    },
545    #[error("invalid sender account: {0}")]
546    InvalidSenderAccount(AccountId),
547    #[error(
548        "account does not contain the basic wallet, fungible faucet or non-fungible faucet \
549         interfaces which are needed to support the send_notes script generation"
550    )]
551    UnsupportedAccountInterface,
552}
553
554// HELPER FUNCTIONS
555// ================================================================================================
556
557/// Validates that `interface` exposes `mint_and_send_root` and does not delegate minting to an
558/// authority.
559///
560/// A faucet that delegates minting is a network faucet: it mints exclusively via MINT notes, so the
561/// standard `send_notes` flow would fail at runtime under its OwnerOnly mint policy. Exposing
562/// either access-control component signals this.
563fn validate_faucet(
564    interface: &AccountCodeInterface,
565    mint_and_send_root: AccountProcedureRoot,
566) -> Result<(), SendNotesTransactionScriptError> {
567    if !interface.contains([mint_and_send_root]) {
568        return Err(SendNotesTransactionScriptError::UnsupportedAccountInterface);
569    }
570
571    let is_authority_controlled = interface.contains(Ownable2Step::code().procedure_roots())
572        || interface.contains(RoleBasedAccessControl::code().procedure_roots());
573    if is_authority_controlled {
574        return Err(SendNotesTransactionScriptError::UnsupportedAccountInterface);
575    }
576
577    Ok(())
578}
579
580/// Validates that every note is sent by `sender` and carries exactly one asset issued by it with
581/// the `expected_composition`, as both faucet scripts mint exactly one asset of the composition
582/// their faucet type defines per note.
583fn validate_minted_notes(
584    sender: AccountId,
585    notes: &[PartialNote],
586    expected_composition: AssetComposition,
587) -> Result<(), SendNotesTransactionScriptError> {
588    for note in notes {
589        validate_note_sender(sender, note)?;
590
591        if note.assets().num_assets() != 1 {
592            return Err(SendNotesTransactionScriptError::FaucetNoteUnexpectedNumAssets);
593        }
594        let asset = note.assets().iter().next().expect("note should contain an asset");
595        if asset.faucet_id() != sender {
596            return Err(SendNotesTransactionScriptError::IssuanceFaucetMismatch(asset.faucet_id()));
597        }
598
599        let composition = asset.id().composition();
600        if composition != expected_composition {
601            return Err(SendNotesTransactionScriptError::AssetCompositionMismatch {
602                expected: expected_composition,
603                actual: composition,
604            });
605        }
606    }
607    Ok(())
608}
609
610/// Validates that `note` is sent by `sender`.
611fn validate_note_sender(
612    sender: AccountId,
613    note: &PartialNote,
614) -> Result<(), SendNotesTransactionScriptError> {
615    if note.metadata().sender() != sender {
616        return Err(SendNotesTransactionScriptError::InvalidSenderAccount(
617            note.metadata().sender(),
618        ));
619    }
620    Ok(())
621}
622
623/// Encodes the notes and expiration delta into the payload element expected by the `send_notes`
624/// MASM scripts. The payload structure is as follows:
625/// ```text
626/// word 0 (header):             [num_notes, expiration_delta, 0, 0]
627/// per note record:
628///   word 0:                    RECIPIENT
629///   word 1:                    [tag, note_type, num_assets, num_attachments]
630///   num_assets * 2 words:      ASSET_ID, ASSET_VALUE
631///   num_attachments * 2 words: [attachment_scheme, 0, 0, 0], ATTACHMENT_COMMITMENT
632/// ```
633fn encode_payload(notes: &[PartialNote], expiration_delta: u16) -> Vec<Felt> {
634    // SAFETY: kernel caps output notes and assets per note below u32::MAX, so these conversions
635    // cannot truncate for any executable transaction.
636    let num_notes = u32::try_from(notes.len()).expect("note count should fit in a u32");
637
638    let mut payload = alloc::vec![Felt::from(num_notes), Felt::from(expiration_delta), ZERO, ZERO];
639    debug_assert_eq!(
640        payload.len(),
641        SendNotesTransactionScript::PAYLOAD_HEADER_NUM_ELEMENTS,
642        "header size should match the advertised constant"
643    );
644
645    for note in notes {
646        let num_assets =
647            u32::try_from(note.assets().num_assets()).expect("asset count should fit in a u32");
648
649        let record_start = payload.len();
650        payload.extend(note.recipient_digest().iter());
651        payload.push(Felt::from(note.metadata().tag()));
652        payload.push(Felt::from(note.metadata().note_type()));
653        debug_assert_eq!(
654            payload.len() - record_start,
655            SendNotesTransactionScript::NOTE_RECORD_NUM_ASSETS_OFFSET,
656            "asset count should sit at the advertised record offset"
657        );
658        payload.push(Felt::from(num_assets));
659        payload.push(Felt::from(note.attachments().num_attachments()));
660        debug_assert_eq!(
661            payload.len() - record_start,
662            SendNotesTransactionScript::NOTE_RECORD_ITEMS_OFFSET,
663            "items should start at the advertised record offset"
664        );
665
666        for asset in note.assets().iter() {
667            let item_start = payload.len();
668            payload.extend(asset.to_id_word().iter());
669            payload.extend(asset.to_value_word().iter());
670            debug_assert_eq!(
671                payload.len() - item_start,
672                SendNotesTransactionScript::ITEM_NUM_ELEMENTS,
673                "an asset item should occupy the advertised number of elements"
674            );
675        }
676
677        for attachment in note.attachments().iter() {
678            payload.push(Felt::from(attachment.attachment_scheme().as_u16()));
679            payload.extend([ZERO; 3]);
680            payload.extend(attachment.to_commitment().iter());
681        }
682    }
683
684    payload
685}