Skip to main content

miden_standards/note/
swap.rs

1use alloc::vec::Vec;
2
3use miden_protocol::account::AccountId;
4use miden_protocol::assembly::Path;
5use miden_protocol::asset::Asset;
6use miden_protocol::crypto::rand::FeltRng;
7use miden_protocol::errors::NoteError;
8use miden_protocol::note::{
9    Note,
10    NoteAssets,
11    NoteAttachments,
12    NoteDetails,
13    NoteRecipient,
14    NoteScript,
15    NoteScriptRoot,
16    NoteStorage,
17    NoteTag,
18    NoteType,
19    PartialNoteMetadata,
20};
21use miden_protocol::utils::sync::LazyLock;
22use miden_protocol::{Felt, ONE, Word};
23
24use crate::StandardsLib;
25use crate::note::P2idNoteStorage;
26
27// NOTE SCRIPT
28// ================================================================================================
29
30/// Path to the SWAP note script procedure in the standards library.
31const SWAP_SCRIPT_PATH: &str = "::miden::standards::notes::swap::main";
32
33// Initialize the SWAP note script only once
34static SWAP_SCRIPT: LazyLock<NoteScript> = LazyLock::new(|| {
35    let standards_lib = StandardsLib::default();
36    let path = Path::new(SWAP_SCRIPT_PATH);
37    NoteScript::from_library_reference(standards_lib.as_ref(), path)
38        .expect("Standards library contains SWAP note script procedure")
39});
40
41// SWAP NOTE
42// ================================================================================================
43
44/// TODO: add docs
45pub struct SwapNote;
46
47impl SwapNote {
48    // CONSTANTS
49    // --------------------------------------------------------------------------------------------
50
51    /// Expected number of storage items of the SWAP note.
52    pub const NUM_STORAGE_ITEMS: usize = SwapNoteStorage::NUM_ITEMS;
53
54    // PUBLIC ACCESSORS
55    // --------------------------------------------------------------------------------------------
56
57    /// Returns the script of the SWAP note.
58    pub fn script() -> NoteScript {
59        SWAP_SCRIPT.clone()
60    }
61
62    /// Returns the SWAP note script root.
63    pub fn script_root() -> NoteScriptRoot {
64        SWAP_SCRIPT.root()
65    }
66
67    // BUILDERS
68    // --------------------------------------------------------------------------------------------
69
70    /// Generates a SWAP note - swap of assets between two accounts - and returns the note as well
71    /// as [`NoteDetails`] for the payback note.
72    ///
73    /// This script enables a swap of 2 assets between the `sender` account and any other account
74    /// that is willing to consume the note. The consumer will receive the `offered_asset` and
75    /// will create a new P2ID note with `sender` as target, containing the `requested_asset`.
76    ///
77    /// See [`SwapPayback`] for how the two payback modes shape the SWAP note storage.
78    ///
79    /// # Errors
80    /// Returns an error if deserialization or compilation of the `SWAP` script fails.
81    pub fn create<R: FeltRng>(
82        sender: AccountId,
83        offered_asset: Asset,
84        requested_asset: Asset,
85        swap_note_type: NoteType,
86        swap_note_attachments: NoteAttachments,
87        payback_note_type: NoteType,
88        rng: &mut R,
89    ) -> Result<(Note, NoteDetails), NoteError> {
90        if requested_asset == offered_asset {
91            return Err(NoteError::other("requested asset same as offered asset"));
92        }
93
94        let serial_num = rng.draw_word();
95
96        // The payback recipient is P2ID(sender) with serial = swap_serial + 1, in both modes.
97        // `create` defaults the payback target to the sender; the storage and script support
98        // any target (see https://github.com/0xMiden/protocol/issues/2950).
99        let payback_serial_num = payback_serial_from_swap(serial_num);
100        let payback_recipient = P2idNoteStorage::new(sender).into_recipient(payback_serial_num);
101        let payback_assets = NoteAssets::new(vec![requested_asset])?;
102        let payback_note = NoteDetails::new(payback_assets, payback_recipient.clone());
103
104        let payback_tag = NoteTag::with_account_target(sender);
105        let swap_storage = match payback_note_type {
106            NoteType::Private => SwapNoteStorage::new_private(
107                requested_asset,
108                payback_recipient.digest(),
109                payback_tag,
110            ),
111            NoteType::Public => SwapNoteStorage::new_public(requested_asset, sender, payback_tag),
112        };
113
114        let recipient = swap_storage.into_recipient(serial_num);
115
116        // build the tag for the SWAP use case
117        let tag = Self::build_tag(swap_note_type, &offered_asset, &requested_asset);
118
119        // build the outgoing note
120        let metadata = PartialNoteMetadata::new(sender, swap_note_type).with_tag(tag);
121        let assets = NoteAssets::new(vec![offered_asset])?;
122        let note = Note::with_attachments(assets, metadata, recipient, swap_note_attachments);
123
124        Ok((note, payback_note))
125    }
126
127    /// Returns a note tag for a swap note with the specified parameters.
128    ///
129    /// The tag is laid out as follows:
130    ///
131    /// ```text
132    /// [
133    ///   note_type (1 bit) | script_root (15 bits)
134    ///   | offered_asset_faucet_id (8 bits) | requested_asset_faucet_id (8 bits)
135    /// ]
136    /// ```
137    ///
138    /// The script root serves as the use case identifier of the SWAP tag.
139    pub fn build_tag(
140        note_type: NoteType,
141        offered_asset: &Asset,
142        requested_asset: &Asset,
143    ) -> NoteTag {
144        let swap_root_bytes = Self::script().root().as_bytes();
145        // Construct the swap use case ID from the 15 most significant bits of the script root. This
146        // leaves the most significant bit zero.
147        let mut swap_use_case_id = (swap_root_bytes[0] as u16) << 7;
148        swap_use_case_id |= (swap_root_bytes[1] >> 1) as u16;
149
150        // Get bits 0..8 from the faucet IDs of both assets which will form the tag payload.
151        let offered_asset_id: u64 = offered_asset.faucet_id().prefix().into();
152        let offered_asset_tag = (offered_asset_id >> 56) as u8;
153
154        let requested_asset_id: u64 = requested_asset.faucet_id().prefix().into();
155        let requested_asset_tag = (requested_asset_id >> 56) as u8;
156
157        let asset_pair = ((offered_asset_tag as u16) << 8) | (requested_asset_tag as u16);
158
159        let tag = ((note_type as u8 as u32) << 31)
160            | ((swap_use_case_id as u32) << 16)
161            | asset_pair as u32;
162
163        NoteTag::new(tag)
164    }
165}
166
167// SWAP NOTE STORAGE
168// ================================================================================================
169
170/// Canonical storage representation for a SWAP note.
171///
172/// Maps to the 16-element [`NoteStorage`] layout consumed by the on-chain MASM script:
173///
174/// | Slot      | Field |
175/// |-----------|-------|
176/// | `[0..7]`  | Requested asset (key + value) |
177/// | `[8..11]` | Payback recipient digest (private mode; zero in public mode) |
178/// | `[12]`    | Payback note type |
179/// | `[13]`    | Payback note tag |
180/// | `[14]`    | Payback target account ID suffix (public mode; zero in private mode) |
181/// | `[15]`    | Payback target account ID prefix (public mode; zero in private mode) |
182///
183/// See [`SwapPayback`] for the rationale behind the per-mode shape.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct SwapNoteStorage {
186    requested_asset: Asset,
187    payback_tag: NoteTag,
188    payback: SwapPayback,
189}
190
191/// Mode-specific payback data embedded in [`SwapNoteStorage`].
192///
193/// The variant determines how the payback recipient is materialized at consume time:
194/// - [`SwapPayback::Private`] embeds the precomputed P2ID recipient digest as an opaque value, so
195///   the SWAP storage alone does not reveal who the payback targets.
196/// - [`SwapPayback::Public`] embeds the payback target account id in plaintext, so any consumer can
197///   reconstruct the payback recipient at consume time via `p2id::prepare_note`.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub enum SwapPayback {
200    Private {
201        /// Precomputed P2ID recipient digest for the payback note.
202        recipient: Word,
203    },
204    Public {
205        /// Account ID that will receive the payback note.
206        payback_target_id: AccountId,
207    },
208}
209
210impl SwapNoteStorage {
211    // CONSTANTS
212    // --------------------------------------------------------------------------------------------
213
214    /// Expected number of storage items of the SWAP note.
215    pub const NUM_ITEMS: usize = 16;
216
217    // CONSTRUCTORS
218    // --------------------------------------------------------------------------------------------
219
220    /// Creates a new SWAP note storage for a private payback.
221    pub fn new_private(
222        requested_asset: Asset,
223        payback_recipient: Word,
224        payback_tag: NoteTag,
225    ) -> Self {
226        Self {
227            requested_asset,
228            payback_tag,
229            payback: SwapPayback::Private { recipient: payback_recipient },
230        }
231    }
232
233    /// Creates a new SWAP note storage for a public payback.
234    pub fn new_public(
235        requested_asset: Asset,
236        payback_target_id: AccountId,
237        payback_tag: NoteTag,
238    ) -> Self {
239        Self {
240            requested_asset,
241            payback_tag,
242            payback: SwapPayback::Public { payback_target_id },
243        }
244    }
245
246    // PUBLIC ACCESSORS
247    // --------------------------------------------------------------------------------------------
248
249    /// Returns the payback note type implied by the payback variant.
250    pub fn payback_note_type(&self) -> NoteType {
251        match self.payback {
252            SwapPayback::Private { .. } => NoteType::Private,
253            SwapPayback::Public { .. } => NoteType::Public,
254        }
255    }
256
257    /// Returns the requested asset.
258    pub fn requested_asset(&self) -> Asset {
259        self.requested_asset
260    }
261
262    /// Returns the tag attached to the payback note.
263    pub fn payback_tag(&self) -> NoteTag {
264        self.payback_tag
265    }
266
267    /// Returns the payback variant of this storage.
268    pub fn payback(&self) -> &SwapPayback {
269        &self.payback
270    }
271
272    /// Consumes the storage and returns a SWAP [`NoteRecipient`] with the provided serial number.
273    ///
274    /// Notes created with this recipient will be SWAP notes whose storage encodes the payback
275    /// configuration and the requested asset stored in this [`SwapNoteStorage`].
276    pub fn into_recipient(self, serial_num: Word) -> NoteRecipient {
277        NoteRecipient::new(serial_num, SwapNote::script(), NoteStorage::from(self))
278    }
279}
280
281impl From<SwapNoteStorage> for NoteStorage {
282    fn from(storage: SwapNoteStorage) -> Self {
283        let mut storage_values = Vec::with_capacity(SwapNoteStorage::NUM_ITEMS);
284
285        // [0..7] requested asset
286        storage_values.extend_from_slice(&storage.requested_asset.as_elements());
287
288        match storage.payback {
289            SwapPayback::Private { recipient } => {
290                // [8..11] payback recipient digest
291                storage_values.extend_from_slice(recipient.as_elements());
292                // [12] payback note type
293                storage_values.push(Felt::from(NoteType::Private.as_u8()));
294                // [13] payback tag
295                storage_values.push(Felt::from(storage.payback_tag.as_u32()));
296                // [14..15] payback target id (zero in private mode)
297                storage_values.extend_from_slice(&[Felt::ZERO; 2]);
298            },
299            SwapPayback::Public { payback_target_id } => {
300                // [8..11] payback recipient (zero in public mode)
301                storage_values.extend_from_slice(&[Felt::ZERO; 4]);
302                // [12] payback note type
303                storage_values.push(Felt::from(NoteType::Public.as_u8()));
304                // [13] payback tag
305                storage_values.push(Felt::from(storage.payback_tag.as_u32()));
306                // [14..15] payback target id (suffix, prefix)
307                storage_values.push(payback_target_id.suffix());
308                storage_values.push(payback_target_id.prefix().as_felt());
309            },
310        }
311
312        NoteStorage::new(storage_values)
313            .expect("number of storage items should not exceed max storage items")
314    }
315}
316
317/// Deserializes [`SwapNoteStorage`] from a slice of exactly 16 [`Felt`]s.
318impl TryFrom<&[Felt]> for SwapNoteStorage {
319    type Error = NoteError;
320
321    fn try_from(note_storage: &[Felt]) -> Result<Self, Self::Error> {
322        if note_storage.len() != Self::NUM_ITEMS {
323            return Err(NoteError::InvalidNoteStorageLength {
324                expected: Self::NUM_ITEMS,
325                actual: note_storage.len(),
326            });
327        }
328
329        // [0..7] = requested asset (key + value)
330        let key = Word::new([note_storage[0], note_storage[1], note_storage[2], note_storage[3]]);
331        let value = Word::new([note_storage[4], note_storage[5], note_storage[6], note_storage[7]]);
332        let requested_asset = Asset::from_id_and_value_words(key, value)
333            .map_err(|err| NoteError::other_with_source("failed to parse requested asset", err))?;
334
335        // [12] = payback_note_type
336        let payback_note_type = NoteType::try_from(
337            u8::try_from(note_storage[12].as_canonical_u64())
338                .map_err(|_| NoteError::other("payback_note_type exceeds u8"))?,
339        )
340        .map_err(|err| NoteError::other_with_source("failed to parse payback note type", err))?;
341
342        // [13] = payback tag
343        let payback_tag_u32 = u32::try_from(note_storage[13].as_canonical_u64())
344            .map_err(|_| NoteError::other("SWAP payback_tag exceeds u32"))?;
345        let payback_tag = NoteTag::new(payback_tag_u32);
346
347        let payback = match payback_note_type {
348            NoteType::Private => {
349                // [14..15] must be zero so a private SWAP cannot leak a payback target id.
350                if note_storage[14].as_canonical_u64() != 0
351                    || note_storage[15].as_canonical_u64() != 0
352                {
353                    return Err(NoteError::other(
354                        "SWAP private payback must have payback target id slots cleared",
355                    ));
356                }
357
358                // [8..11] payback recipient digest
359                let recipient = Word::new([
360                    note_storage[8],
361                    note_storage[9],
362                    note_storage[10],
363                    note_storage[11],
364                ]);
365
366                SwapPayback::Private { recipient }
367            },
368            NoteType::Public => {
369                // [8..11] must be zero so the storage shape is unambiguous.
370                if note_storage[8..=11].iter().any(|felt| felt.as_canonical_u64() != 0) {
371                    return Err(NoteError::other(
372                        "SWAP public payback must have recipient slots cleared",
373                    ));
374                }
375
376                let payback_target_id = AccountId::try_from_elements(
377                    note_storage[14],
378                    note_storage[15],
379                )
380                .map_err(|err| {
381                    NoteError::other_with_source("failed to parse payback target account ID", err)
382                })?;
383
384                SwapPayback::Public { payback_target_id }
385            },
386        };
387
388        Ok(Self { requested_asset, payback_tag, payback })
389    }
390}
391
392/// Returns the P2ID payback serial derived from a SWAP note's own serial number.
393///
394/// The SWAP MASM script computes the payback's serial by incrementing the least significant
395/// element of the SWAP serial. Creators can recompute this offline to track or consume the
396/// payback note after the SWAP is filled.
397pub fn payback_serial_from_swap(swap_serial: Word) -> Word {
398    let elements = swap_serial.as_elements();
399    Word::new([elements[0] + ONE, elements[1], elements[2], elements[3]])
400}
401
402// TESTS
403// ================================================================================================
404
405#[cfg(test)]
406mod tests {
407
408    use assert_matches::assert_matches;
409    use miden_protocol::account::{AccountIdVersion, AccountType, AssetCallbackFlag};
410    use miden_protocol::asset::{FungibleAsset, NonFungibleAsset, NonFungibleAssetDetails};
411    use miden_protocol::note::{NoteStorage, NoteType};
412    use miden_protocol::testing::account_id::{
413        ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
414        ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET,
415    };
416
417    use super::*;
418
419    fn fungible_faucet() -> AccountId {
420        ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap()
421    }
422
423    fn non_fungible_faucet() -> AccountId {
424        ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET.try_into().unwrap()
425    }
426
427    fn fungible_asset() -> Asset {
428        Asset::Fungible(FungibleAsset::new(fungible_faucet(), 1000).unwrap())
429    }
430
431    fn non_fungible_asset() -> Asset {
432        let details = NonFungibleAssetDetails::new(non_fungible_faucet(), vec![0xaa, 0xbb]);
433        Asset::NonFungible(NonFungibleAsset::new(&details))
434    }
435
436    fn dummy_target_id() -> AccountId {
437        AccountId::builder().account_type(AccountType::Private).build_with_seed([1; 32])
438    }
439
440    fn dummy_recipient_digest() -> Word {
441        Word::new([Felt::from(7u32), Felt::from(11u32), Felt::from(13u32), Felt::from(17u32)])
442    }
443
444    fn dummy_payback_tag() -> NoteTag {
445        NoteTag::new(0xabcd1234)
446    }
447
448    #[test]
449    fn swap_note_storage_round_trip_fungible_private() {
450        let storage = SwapNoteStorage::new_private(
451            fungible_asset(),
452            dummy_recipient_digest(),
453            dummy_payback_tag(),
454        );
455
456        let note_storage = NoteStorage::from(storage.clone());
457        assert_eq!(note_storage.num_items() as usize, SwapNoteStorage::NUM_ITEMS);
458        assert_eq!(storage.payback_note_type(), NoteType::Private);
459        assert_eq!(storage.requested_asset(), fungible_asset());
460        assert_eq!(storage.payback_tag(), dummy_payback_tag());
461        match storage.payback() {
462            SwapPayback::Private { recipient } => {
463                assert_eq!(*recipient, dummy_recipient_digest());
464            },
465            SwapPayback::Public { .. } => panic!("expected private payback"),
466        }
467
468        let parsed =
469            SwapNoteStorage::try_from(note_storage.items()).expect("round trip should succeed");
470        assert_eq!(parsed, storage);
471    }
472
473    #[test]
474    fn swap_note_storage_round_trip_non_fungible_public() {
475        let target = dummy_target_id();
476        let storage =
477            SwapNoteStorage::new_public(non_fungible_asset(), target, dummy_payback_tag());
478
479        let note_storage = NoteStorage::from(storage.clone());
480        assert_eq!(note_storage.num_items() as usize, SwapNoteStorage::NUM_ITEMS);
481        assert_eq!(storage.payback_note_type(), NoteType::Public);
482        assert_eq!(storage.requested_asset(), non_fungible_asset());
483        assert_eq!(storage.payback_tag(), dummy_payback_tag());
484        match storage.payback() {
485            SwapPayback::Public { payback_target_id } => {
486                assert_eq!(*payback_target_id, target);
487            },
488            SwapPayback::Private { .. } => panic!("expected public payback"),
489        }
490
491        let parsed =
492            SwapNoteStorage::try_from(note_storage.items()).expect("round trip should succeed");
493        assert_eq!(parsed, storage);
494    }
495
496    #[test]
497    fn swap_note_storage_private_rejects_dirty_target_slots() {
498        let mut items: Vec<Felt> = NoteStorage::from(SwapNoteStorage::new_private(
499            fungible_asset(),
500            dummy_recipient_digest(),
501            dummy_payback_tag(),
502        ))
503        .items()
504        .to_vec();
505
506        // Inject a non-zero target suffix in the slot that must stay clear for private payback.
507        items[14] = Felt::from(1u32);
508        let err = SwapNoteStorage::try_from(items.as_slice())
509            .expect_err("private payback with a dirty target slot must be rejected");
510        assert_matches!(
511            err,
512            NoteError::Other { error_msg, .. }
513                if error_msg == "SWAP private payback must have payback target id slots cleared".into()
514        );
515    }
516
517    #[test]
518    fn swap_note_storage_public_rejects_dirty_private_slots() {
519        let mut items: Vec<Felt> = NoteStorage::from(SwapNoteStorage::new_public(
520            fungible_asset(),
521            dummy_target_id(),
522            dummy_payback_tag(),
523        ))
524        .items()
525        .to_vec();
526
527        // Inject a non-zero recipient felt in the slot that must stay clear for public payback.
528        items[8] = Felt::from(1u32);
529        let err = SwapNoteStorage::try_from(items.as_slice())
530            .expect_err("public payback with a dirty recipient slot must be rejected");
531        assert_matches!(
532            err,
533            NoteError::Other { error_msg, .. }
534                if error_msg == "SWAP public payback must have recipient slots cleared".into()
535        );
536    }
537
538    #[test]
539    fn swap_tag() {
540        // Construct an ID that starts with 0xcdb1.
541        let mut fungible_faucet_id_bytes = [0; 15];
542        fungible_faucet_id_bytes[0] = 0xcd;
543        fungible_faucet_id_bytes[1] = 0xb1;
544
545        // Construct an ID that starts with 0xabec.
546        let mut non_fungible_faucet_id_bytes = [0; 15];
547        non_fungible_faucet_id_bytes[0] = 0xab;
548        non_fungible_faucet_id_bytes[1] = 0xec;
549
550        let offered_asset = Asset::Fungible(
551            FungibleAsset::new(
552                AccountId::dummy(
553                    fungible_faucet_id_bytes,
554                    AccountIdVersion::Version1,
555                    AccountType::Public,
556                    AssetCallbackFlag::Disabled,
557                ),
558                2500,
559            )
560            .unwrap(),
561        );
562
563        let requested_asset =
564            Asset::NonFungible(NonFungibleAsset::new(&NonFungibleAssetDetails::new(
565                AccountId::dummy(
566                    non_fungible_faucet_id_bytes,
567                    AccountIdVersion::Version1,
568                    AccountType::Public,
569                    AssetCallbackFlag::Disabled,
570                ),
571                vec![0xaa, 0xbb, 0xcc, 0xdd],
572            )));
573
574        // The fungible ID starts with 0xcdb1.
575        // The non fungible ID starts with 0xabec.
576        // The expected tag payload is thus 0xcdab.
577        let expected_asset_pair = 0xcdab;
578
579        let note_type = NoteType::Public;
580        let actual_tag = SwapNote::build_tag(note_type, &offered_asset, &requested_asset);
581
582        assert_eq!(actual_tag.as_u32() as u16, expected_asset_pair, "asset pair should match");
583        assert_eq!((actual_tag.as_u32() >> 31) as u8, note_type as u8, "note type should match");
584        // Check the 8 bits of the first script root byte.
585        assert_eq!(
586            (actual_tag.as_u32() >> 23) as u8,
587            SwapNote::script_root().as_bytes()[0],
588            "swap script root byte 0 should match"
589        );
590        // Extract the 7 bits of the second script root byte and shift for comparison.
591        assert_eq!(
592            ((actual_tag.as_u32() & 0b00000000_01111111_00000000_00000000) >> 16) as u8,
593            SwapNote::script_root().as_bytes()[1] >> 1,
594            "swap script root byte 1 should match with the highest bit set to zero"
595        );
596    }
597}