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    NoteAttachment,
12    NoteAttachments,
13    NoteDetails,
14    NoteRecipient,
15    NoteScript,
16    NoteScriptRoot,
17    NoteStorage,
18    NoteTag,
19    NoteType,
20    PartialNoteMetadata,
21};
22use miden_protocol::utils::sync::LazyLock;
23use miden_protocol::{Felt, ONE, Word};
24
25use crate::StandardsLib;
26use crate::note::costs::{NoteConsumptionCost, SWAP_CONSUMPTION_CYCLES};
27use crate::note::{P2idNote, P2idNoteStorage};
28
29// NOTE SCRIPT
30// ================================================================================================
31
32/// Path to the SWAP note script procedure in the standards library.
33const SWAP_SCRIPT_PATH: &str = "::miden::standards::notes::swap::main";
34
35// Initialize the SWAP note script only once
36static SWAP_SCRIPT: LazyLock<NoteScript> = LazyLock::new(|| {
37    let standards_lib = StandardsLib::default();
38    let path = Path::new(SWAP_SCRIPT_PATH);
39    NoteScript::from_package_reference(standards_lib.as_ref(), path)
40        .expect("Standards library contains SWAP note script procedure")
41});
42
43// SWAP NOTE
44// ================================================================================================
45
46/// A SWAP note: offers `offered_asset` in exchange for `requested_asset`.
47///
48/// Any account willing to pay the requested asset can consume the note: the consumer receives the
49/// offered asset and, in the same transaction, the script creates a P2ID payback note carrying the
50/// requested asset back to the swap creator. [`SwapNote::payback_note_details`] returns that
51/// payback note's [`NoteDetails`], which the creator needs to track and consume it once the swap is
52/// filled.
53///
54/// Construct one with the [builder](SwapNote::builder), which defaults both the note type and the
55/// payback note type to [`NoteType::Private`] and adds no attachments; convert it into a protocol
56/// [`Note`] infallibly via `Note::from`.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct SwapNote {
59    sender: AccountId,
60    offered_asset: Asset,
61    serial_number: Word,
62    note_type: NoteType,
63    storage: SwapNoteStorage,
64    attachments: NoteAttachments,
65}
66
67#[bon::bon]
68impl SwapNote {
69    /// Builds a new [`SwapNote`].
70    ///
71    /// The payback note targets the `sender`; the storage and script support any target. See
72    /// [`SwapPayback`] for how `payback_note_type` shapes the SWAP note storage.
73    ///
74    /// # Errors
75    ///
76    /// Returns an error if:
77    /// - The requested asset is the same as the offered asset.
78    /// - The attachments exceed their protocol limit (see [`NoteAttachments::new`]).
79    #[builder]
80    pub fn new(
81        #[builder(field)] attachments: Vec<NoteAttachment>,
82        sender: AccountId,
83        #[builder(into)] offered_asset: Asset,
84        #[builder(into)] requested_asset: Asset,
85        /// Must be drawn from a cryptographically secure RNG, e.g. via the builder's
86        /// `generate_serial_number`: two SWAP notes sharing a serial number derive the same
87        /// payback note, of which only one can be created.
88        serial_number: Word,
89        /// Defaults to [`NoteType::Private`], which only the counterparties the creator shares
90        /// the note with can fill. A SWAP note offered to the network at large must be set to
91        /// [`NoteType::Public`] explicitly.
92        #[builder(default)]
93        note_type: NoteType,
94        /// Defaults to [`NoteType::Private`], so the payback note's details are known only to the
95        /// creator, who needs the [`NoteDetails`] returned by [`SwapNote::payback_note_details`]
96        /// to consume it. Set to [`NoteType::Public`] to have the network store those
97        /// details instead.
98        #[builder(default)]
99        payback_note_type: NoteType,
100    ) -> Result<Self, NoteError> {
101        if requested_asset == offered_asset {
102            return Err(NoteError::other("requested asset same as offered asset"));
103        }
104
105        let attachments = NoteAttachments::new(attachments)?;
106
107        let payback_tag = NoteTag::with_account_target(sender);
108
109        let storage = match payback_note_type {
110            NoteType::Private => SwapNoteStorage::new_private(
111                requested_asset,
112                Self::payback_recipient(sender, serial_number).digest(),
113                payback_tag,
114            ),
115            NoteType::Public => SwapNoteStorage::new_public(requested_asset, sender, payback_tag),
116        };
117
118        Ok(Self {
119            sender,
120            offered_asset,
121            serial_number,
122            note_type,
123            storage,
124            attachments,
125        })
126    }
127}
128
129impl SwapNote {
130    // CONSTANTS
131    // --------------------------------------------------------------------------------------------
132
133    /// Expected number of storage items of the SWAP note.
134    pub const NUM_STORAGE_ITEMS: usize = SwapNoteStorage::NUM_ITEMS;
135
136    // PUBLIC ACCESSORS
137    // --------------------------------------------------------------------------------------------
138
139    /// Returns the script of the SWAP note.
140    pub fn script() -> NoteScript {
141        SWAP_SCRIPT.clone()
142    }
143
144    /// Returns the SWAP note script root.
145    pub fn script_root() -> NoteScriptRoot {
146        SWAP_SCRIPT.root()
147    }
148
149    /// Returns the account ID of the note's sender, which is also the payback note's target.
150    pub fn sender(&self) -> AccountId {
151        self.sender
152    }
153
154    /// Returns the asset offered by the note's sender.
155    pub fn offered_asset(&self) -> Asset {
156        self.offered_asset
157    }
158
159    /// Returns the asset the consumer must pay to claim the offered asset.
160    pub fn requested_asset(&self) -> Asset {
161        self.storage().requested_asset()
162    }
163
164    /// Returns the note's serial number.
165    pub fn serial_number(&self) -> Word {
166        self.serial_number
167    }
168
169    /// Returns the note's type.
170    pub fn note_type(&self) -> NoteType {
171        self.note_type
172    }
173
174    /// Returns the type of the payback note created when the swap is filled.
175    pub fn payback_note_type(&self) -> NoteType {
176        self.storage().payback_note_type()
177    }
178
179    /// Returns the attachments carried by the note.
180    pub fn attachments(&self) -> &NoteAttachments {
181        &self.attachments
182    }
183
184    /// Returns the note's storage.
185    pub fn storage(&self) -> &SwapNoteStorage {
186        &self.storage
187    }
188
189    /// Returns the [`NoteDetails`] of the payback note that the SWAP script creates when the note
190    /// is consumed.
191    pub fn payback_note_details(&self) -> NoteDetails {
192        let assets = NoteAssets::new(vec![self.requested_asset()])
193            .expect("a single asset never exceeds the note asset limit");
194
195        NoteDetails::new(assets, Self::payback_recipient(self.sender, self.serial_number))
196    }
197
198    // ASSOCIATED FUNCTIONS
199    // --------------------------------------------------------------------------------------------
200
201    /// Returns a note tag for a swap note with the specified parameters.
202    ///
203    /// The tag is laid out as follows:
204    ///
205    /// ```text
206    /// [
207    ///   note_type (1 bit) | script_root (15 bits)
208    ///   | offered_asset_faucet_id (8 bits) | requested_asset_faucet_id (8 bits)
209    /// ]
210    /// ```
211    ///
212    /// The script root serves as the use case identifier of the SWAP tag.
213    pub fn create_tag(
214        note_type: NoteType,
215        offered_asset: &Asset,
216        requested_asset: &Asset,
217    ) -> NoteTag {
218        let swap_root_bytes = Self::script().root().as_bytes();
219        // Construct the swap use case ID from the 15 most significant bits of the script root. This
220        // leaves the most significant bit zero.
221        let mut swap_use_case_id = (swap_root_bytes[0] as u16) << 7;
222        swap_use_case_id |= (swap_root_bytes[1] >> 1) as u16;
223
224        // Get bits 0..8 from the faucet IDs of both assets which will form the tag payload.
225        let offered_asset_id: u64 = offered_asset.faucet_id().prefix().into();
226        let offered_asset_tag = (offered_asset_id >> 56) as u8;
227
228        let requested_asset_id: u64 = requested_asset.faucet_id().prefix().into();
229        let requested_asset_tag = (requested_asset_id >> 56) as u8;
230
231        let asset_pair = ((offered_asset_tag as u16) << 8) | (requested_asset_tag as u16);
232
233        let tag = ((note_type as u8 as u32) << 31)
234            | ((swap_use_case_id as u32) << 16)
235            | asset_pair as u32;
236
237        NoteTag::new(tag)
238    }
239
240    // HELPERS
241    // --------------------------------------------------------------------------------------------
242
243    /// Returns the payback note's recipient, which is P2ID(sender) in both payback modes.
244    fn payback_recipient(sender: AccountId, serial_number: Word) -> NoteRecipient {
245        P2idNoteStorage::new(sender).into_recipient(payback_serial_from_swap(serial_number))
246    }
247}
248
249// BUILDER EXTENSIONS
250// ================================================================================================
251
252impl<S: swap_note_builder::State> SwapNoteBuilder<S> {
253    /// Adds a single attachment to the note.
254    pub fn attachment(mut self, attachment: impl Into<NoteAttachment>) -> Self {
255        self.attachments.push(attachment.into());
256        self
257    }
258
259    /// Adds multiple attachments to the note.
260    pub fn attachments(
261        mut self,
262        attachments: impl IntoIterator<Item = impl Into<NoteAttachment>>,
263    ) -> Self {
264        self.attachments.extend(attachments.into_iter().map(Into::into));
265        self
266    }
267}
268
269impl<S: swap_note_builder::State> SwapNoteBuilder<S>
270where
271    S::SerialNumber: swap_note_builder::IsUnset,
272{
273    /// Draws a serial number from `rng` and sets it on the builder.
274    pub fn generate_serial_number(
275        self,
276        rng: &mut impl FeltRng,
277    ) -> SwapNoteBuilder<swap_note_builder::SetSerialNumber<S>> {
278        self.serial_number(rng.draw_word())
279    }
280}
281
282// CONVERSIONS
283// ================================================================================================
284
285impl From<SwapNote> for Note {
286    fn from(note: SwapNote) -> Self {
287        let SwapNote {
288            sender,
289            offered_asset,
290            serial_number,
291            note_type,
292            storage,
293            attachments,
294        } = note;
295
296        let tag = SwapNote::create_tag(note_type, &offered_asset, &storage.requested_asset());
297        let metadata = PartialNoteMetadata::new(sender, note_type).with_tag(tag);
298        let recipient = storage.into_recipient(serial_number);
299
300        let assets = NoteAssets::new(vec![offered_asset])
301            .expect("a single asset never exceeds the note asset limit");
302
303        Note::with_attachments(assets, metadata, recipient, attachments)
304    }
305}
306
307// SWAP NOTE STORAGE
308// ================================================================================================
309
310/// Canonical storage representation for a SWAP note.
311///
312/// Maps to the 16-element [`NoteStorage`] layout consumed by the on-chain MASM script:
313///
314/// | Slot      | Field |
315/// |-----------|-------|
316/// | `[0..7]`  | Requested asset (key + value) |
317/// | `[8..11]` | Payback recipient digest (private mode; zero in public mode) |
318/// | `[12]`    | Payback note type |
319/// | `[13]`    | Payback note tag |
320/// | `[14]`    | Payback target account ID suffix (public mode; zero in private mode) |
321/// | `[15]`    | Payback target account ID prefix (public mode; zero in private mode) |
322///
323/// See [`SwapPayback`] for the rationale behind the per-mode shape.
324#[derive(Debug, Clone, PartialEq, Eq)]
325pub struct SwapNoteStorage {
326    requested_asset: Asset,
327    payback_tag: NoteTag,
328    payback: SwapPayback,
329}
330
331/// Mode-specific payback data embedded in [`SwapNoteStorage`].
332///
333/// The variant determines how the payback recipient is materialized at consume time:
334/// - [`SwapPayback::Private`] embeds the precomputed P2ID recipient digest as an opaque value, so
335///   the SWAP storage alone does not reveal who the payback targets.
336/// - [`SwapPayback::Public`] embeds the payback target account id in plaintext, so any consumer can
337///   reconstruct the payback recipient at consume time via `p2id::prepare_note`.
338#[derive(Debug, Clone, PartialEq, Eq)]
339pub enum SwapPayback {
340    Private {
341        /// Precomputed P2ID recipient digest for the payback note.
342        recipient: Word,
343    },
344    Public {
345        /// Account ID that will receive the payback note.
346        payback_target_id: AccountId,
347    },
348}
349
350impl SwapNoteStorage {
351    // CONSTANTS
352    // --------------------------------------------------------------------------------------------
353
354    /// Expected number of storage items of the SWAP note.
355    pub const NUM_ITEMS: usize = 16;
356
357    // CONSTRUCTORS
358    // --------------------------------------------------------------------------------------------
359
360    /// Creates a new SWAP note storage for a private payback.
361    pub fn new_private(
362        requested_asset: Asset,
363        payback_recipient: Word,
364        payback_tag: NoteTag,
365    ) -> Self {
366        Self {
367            requested_asset,
368            payback_tag,
369            payback: SwapPayback::Private { recipient: payback_recipient },
370        }
371    }
372
373    /// Creates a new SWAP note storage for a public payback.
374    pub fn new_public(
375        requested_asset: Asset,
376        payback_target_id: AccountId,
377        payback_tag: NoteTag,
378    ) -> Self {
379        Self {
380            requested_asset,
381            payback_tag,
382            payback: SwapPayback::Public { payback_target_id },
383        }
384    }
385
386    // PUBLIC ACCESSORS
387    // --------------------------------------------------------------------------------------------
388
389    /// Returns the payback note type implied by the payback variant.
390    pub fn payback_note_type(&self) -> NoteType {
391        match self.payback {
392            SwapPayback::Private { .. } => NoteType::Private,
393            SwapPayback::Public { .. } => NoteType::Public,
394        }
395    }
396
397    /// Returns the requested asset.
398    pub fn requested_asset(&self) -> Asset {
399        self.requested_asset
400    }
401
402    /// Returns the tag attached to the payback note.
403    pub fn payback_tag(&self) -> NoteTag {
404        self.payback_tag
405    }
406
407    /// Returns the payback variant of this storage.
408    pub fn payback(&self) -> &SwapPayback {
409        &self.payback
410    }
411
412    /// Consumes the storage and returns a SWAP [`NoteRecipient`] with the provided serial number.
413    ///
414    /// Notes created with this recipient will be SWAP notes whose storage encodes the payback
415    /// configuration and the requested asset stored in this [`SwapNoteStorage`].
416    pub fn into_recipient(self, serial_num: Word) -> NoteRecipient {
417        NoteRecipient::new(serial_num, SwapNote::script(), NoteStorage::from(self))
418    }
419}
420
421impl From<SwapNoteStorage> for NoteStorage {
422    fn from(storage: SwapNoteStorage) -> Self {
423        let mut storage_values = Vec::with_capacity(SwapNoteStorage::NUM_ITEMS);
424
425        // [0..7] requested asset
426        storage_values.extend_from_slice(&storage.requested_asset.as_elements());
427
428        match storage.payback {
429            SwapPayback::Private { recipient } => {
430                // [8..11] payback recipient digest
431                storage_values.extend_from_slice(recipient.as_elements());
432                // [12] payback note type
433                storage_values.push(Felt::from(NoteType::Private.as_u8()));
434                // [13] payback tag
435                storage_values.push(Felt::from(storage.payback_tag.as_u32()));
436                // [14..15] payback target id (zero in private mode)
437                storage_values.extend_from_slice(&[Felt::ZERO; 2]);
438            },
439            SwapPayback::Public { payback_target_id } => {
440                // [8..11] payback recipient (zero in public mode)
441                storage_values.extend_from_slice(&[Felt::ZERO; 4]);
442                // [12] payback note type
443                storage_values.push(Felt::from(NoteType::Public.as_u8()));
444                // [13] payback tag
445                storage_values.push(Felt::from(storage.payback_tag.as_u32()));
446                // [14..15] payback target id (suffix, prefix)
447                storage_values.push(payback_target_id.suffix());
448                storage_values.push(payback_target_id.prefix().as_felt());
449            },
450        }
451
452        NoteStorage::new(storage_values)
453            .expect("number of storage items should not exceed max storage items")
454    }
455}
456
457/// Deserializes [`SwapNoteStorage`] from a slice of exactly 16 [`Felt`]s.
458impl TryFrom<&[Felt]> for SwapNoteStorage {
459    type Error = NoteError;
460
461    fn try_from(note_storage: &[Felt]) -> Result<Self, Self::Error> {
462        if note_storage.len() != Self::NUM_ITEMS {
463            return Err(NoteError::InvalidNoteStorageLength {
464                expected: Self::NUM_ITEMS,
465                actual: note_storage.len(),
466            });
467        }
468
469        // [0..7] = requested asset (key + value)
470        let key = Word::new([note_storage[0], note_storage[1], note_storage[2], note_storage[3]]);
471        let value = Word::new([note_storage[4], note_storage[5], note_storage[6], note_storage[7]]);
472        let requested_asset = Asset::from_id_and_value_words(key, value)
473            .map_err(|err| NoteError::other_with_source("failed to parse requested asset", err))?;
474
475        // [12] = payback_note_type
476        let payback_note_type = NoteType::try_from(
477            u8::try_from(note_storage[12].as_canonical_u64())
478                .map_err(|_| NoteError::other("payback_note_type exceeds u8"))?,
479        )
480        .map_err(|err| NoteError::other_with_source("failed to parse payback note type", err))?;
481
482        // [13] = payback tag
483        let payback_tag_u32 = u32::try_from(note_storage[13].as_canonical_u64())
484            .map_err(|_| NoteError::other("SWAP payback_tag exceeds u32"))?;
485        let payback_tag = NoteTag::new(payback_tag_u32);
486
487        let payback = match payback_note_type {
488            NoteType::Private => {
489                // [14..15] must be zero so a private SWAP cannot leak a payback target id.
490                if note_storage[14].as_canonical_u64() != 0
491                    || note_storage[15].as_canonical_u64() != 0
492                {
493                    return Err(NoteError::other(
494                        "SWAP private payback must have payback target id slots cleared",
495                    ));
496                }
497
498                // [8..11] payback recipient digest
499                let recipient = Word::new([
500                    note_storage[8],
501                    note_storage[9],
502                    note_storage[10],
503                    note_storage[11],
504                ]);
505
506                SwapPayback::Private { recipient }
507            },
508            NoteType::Public => {
509                // [8..11] must be zero so the storage shape is unambiguous.
510                if note_storage[8..=11].iter().any(|felt| felt.as_canonical_u64() != 0) {
511                    return Err(NoteError::other(
512                        "SWAP public payback must have recipient slots cleared",
513                    ));
514                }
515
516                let payback_target_id = AccountId::try_from_elements(
517                    note_storage[14],
518                    note_storage[15],
519                )
520                .map_err(|err| {
521                    NoteError::other_with_source("failed to parse payback target account ID", err)
522                })?;
523
524                SwapPayback::Public { payback_target_id }
525            },
526        };
527
528        Ok(Self { requested_asset, payback_tag, payback })
529    }
530}
531
532/// Returns the P2ID payback serial derived from a SWAP note's own serial number.
533///
534/// The SWAP MASM script computes the payback's serial by incrementing the least significant
535/// element of the SWAP serial. Creators can recompute this offline to track or consume the
536/// payback note after the SWAP is filled.
537pub fn payback_serial_from_swap(swap_serial: Word) -> Word {
538    let elements = swap_serial.as_elements();
539    Word::new([elements[0] + ONE, elements[1], elements[2], elements[3]])
540}
541
542// NOTE CONSUMPTION COST
543// ================================================================================================
544
545impl NoteConsumptionCost for SwapNote {
546    fn consumption_cycles() -> u32 {
547        SWAP_CONSUMPTION_CYCLES
548    }
549
550    /// Filling a SWAP note creates the P2ID payback note for the swap creator.
551    fn created_notes() -> Vec<NoteScriptRoot> {
552        vec![P2idNote::script_root()]
553    }
554}
555
556// TESTS
557// ================================================================================================
558
559#[cfg(test)]
560mod tests {
561
562    use assert_matches::assert_matches;
563    use miden_protocol::account::{AccountIdVersion, AccountType, AssetCallbackFlag};
564    use miden_protocol::asset::{FungibleAsset, NonFungibleAsset, NonFungibleAssetDetails};
565    use miden_protocol::crypto::rand::RandomCoin;
566    use miden_protocol::note::{NoteStorage, NoteType};
567    use miden_protocol::testing::account_id::{
568        ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
569        ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET,
570    };
571    use rstest::rstest;
572
573    use super::*;
574    use crate::note::{NetworkAccountTarget, P2idNote};
575
576    fn fungible_faucet() -> AccountId {
577        ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap()
578    }
579
580    fn non_fungible_faucet() -> AccountId {
581        ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET.try_into().unwrap()
582    }
583
584    fn fungible_asset() -> Asset {
585        Asset::Fungible(FungibleAsset::new(fungible_faucet(), 1000).unwrap())
586    }
587
588    fn non_fungible_asset() -> Asset {
589        let details = NonFungibleAssetDetails::new(non_fungible_faucet(), vec![0xaa, 0xbb]);
590        Asset::NonFungible(NonFungibleAsset::new(&details))
591    }
592
593    fn dummy_target_id() -> AccountId {
594        AccountId::builder().account_type(AccountType::Private).build_with_seed([1; 32])
595    }
596
597    fn dummy_recipient_digest() -> Word {
598        Word::new([Felt::from(7u32), Felt::from(11u32), Felt::from(13u32), Felt::from(17u32)])
599    }
600
601    fn dummy_payback_tag() -> NoteTag {
602        NoteTag::new(0xabcd1234)
603    }
604
605    /// The built note must carry the offered asset and a storage that encodes the payback
606    /// configuration, while the payback note details target the sender with the serial number the
607    /// MASM script derives.
608    #[rstest]
609    #[case::private_payback(NoteType::Private)]
610    #[case::public_payback(NoteType::Public)]
611    fn swap_note_builder(#[case] payback_note_type: NoteType) -> anyhow::Result<()> {
612        let sender = dummy_target_id();
613        let attachment = NoteAttachment::with_word(
614            NetworkAccountTarget::ATTACHMENT_SCHEME,
615            dummy_recipient_digest(),
616        );
617        let swap_note_type = NoteType::Public;
618        let swap_note = SwapNote::builder()
619            .sender(sender)
620            .offered_asset(fungible_asset())
621            .requested_asset(non_fungible_asset())
622            .note_type(swap_note_type)
623            .payback_note_type(payback_note_type)
624            .attachment(attachment.clone())
625            .generate_serial_number(&mut RandomCoin::new(Word::from([1, 2, 3, 4u32])))
626            .build()?;
627
628        let serial_number = swap_note.serial_number();
629        let storage = swap_note.storage().clone();
630        let payback_note = swap_note.payback_note_details();
631        let note = Note::from(swap_note);
632
633        assert_eq!(note.metadata().sender(), sender);
634        assert_eq!(note.metadata().note_type(), swap_note_type);
635        assert_eq!(
636            note.metadata().tag(),
637            SwapNote::create_tag(swap_note_type, &fungible_asset(), &non_fungible_asset())
638        );
639        assert_eq!(note.assets().num_assets(), 1);
640        assert_eq!(note.assets().iter().next(), Some(&fungible_asset()));
641        assert_eq!(note.attachments().get(0), Some(&attachment));
642        assert_eq!(note.recipient().script().root(), SwapNote::script_root());
643        assert_eq!(
644            SwapNoteStorage::try_from(note.recipient().storage().items())?,
645            storage,
646            "the note's storage must match the one derived from the SWAP note"
647        );
648        assert_eq!(storage.payback_tag(), NoteTag::with_account_target(sender));
649
650        assert_eq!(payback_note.assets().iter().next(), Some(&non_fungible_asset()));
651        assert_eq!(payback_note.recipient().serial_num(), payback_serial_from_swap(serial_number));
652        assert_eq!(payback_note.recipient().script().root(), P2idNote::script_root());
653
654        // Both payback modes must resolve to the payback note the creator was handed: privately
655        // through the embedded recipient digest, publicly by reconstructing it from the target ID.
656        match (payback_note_type, storage.payback()) {
657            (NoteType::Private, SwapPayback::Private { recipient }) => {
658                assert_eq!(*recipient, payback_note.recipient().digest());
659            },
660            (NoteType::Public, SwapPayback::Public { payback_target_id }) => {
661                assert_eq!(*payback_target_id, sender);
662            },
663            (note_type, payback) => panic!("payback {payback:?} does not match {note_type:?}"),
664        }
665
666        Ok(())
667    }
668
669    #[test]
670    fn swap_note_storage_round_trip_fungible_private() {
671        let storage = SwapNoteStorage::new_private(
672            fungible_asset(),
673            dummy_recipient_digest(),
674            dummy_payback_tag(),
675        );
676
677        let note_storage = NoteStorage::from(storage.clone());
678        assert_eq!(note_storage.num_items() as usize, SwapNoteStorage::NUM_ITEMS);
679        assert_eq!(storage.payback_note_type(), NoteType::Private);
680        assert_eq!(storage.requested_asset(), fungible_asset());
681        assert_eq!(storage.payback_tag(), dummy_payback_tag());
682        match storage.payback() {
683            SwapPayback::Private { recipient } => {
684                assert_eq!(*recipient, dummy_recipient_digest());
685            },
686            SwapPayback::Public { .. } => panic!("expected private payback"),
687        }
688
689        let parsed =
690            SwapNoteStorage::try_from(note_storage.items()).expect("round trip should succeed");
691        assert_eq!(parsed, storage);
692    }
693
694    #[test]
695    fn swap_note_storage_round_trip_non_fungible_public() {
696        let target = dummy_target_id();
697        let storage =
698            SwapNoteStorage::new_public(non_fungible_asset(), target, dummy_payback_tag());
699
700        let note_storage = NoteStorage::from(storage.clone());
701        assert_eq!(note_storage.num_items() as usize, SwapNoteStorage::NUM_ITEMS);
702        assert_eq!(storage.payback_note_type(), NoteType::Public);
703        assert_eq!(storage.requested_asset(), non_fungible_asset());
704        assert_eq!(storage.payback_tag(), dummy_payback_tag());
705        match storage.payback() {
706            SwapPayback::Public { payback_target_id } => {
707                assert_eq!(*payback_target_id, target);
708            },
709            SwapPayback::Private { .. } => panic!("expected public payback"),
710        }
711
712        let parsed =
713            SwapNoteStorage::try_from(note_storage.items()).expect("round trip should succeed");
714        assert_eq!(parsed, storage);
715    }
716
717    #[test]
718    fn swap_note_storage_private_rejects_dirty_target_slots() {
719        let mut items: Vec<Felt> = NoteStorage::from(SwapNoteStorage::new_private(
720            fungible_asset(),
721            dummy_recipient_digest(),
722            dummy_payback_tag(),
723        ))
724        .items()
725        .to_vec();
726
727        // Inject a non-zero target suffix in the slot that must stay clear for private payback.
728        items[14] = Felt::from(1u32);
729        let err = SwapNoteStorage::try_from(items.as_slice())
730            .expect_err("private payback with a dirty target slot must be rejected");
731        assert_matches!(
732            err,
733            NoteError::Other { error_msg, .. }
734                if error_msg == "SWAP private payback must have payback target id slots cleared".into()
735        );
736    }
737
738    #[test]
739    fn swap_note_storage_public_rejects_dirty_private_slots() {
740        let mut items: Vec<Felt> = NoteStorage::from(SwapNoteStorage::new_public(
741            fungible_asset(),
742            dummy_target_id(),
743            dummy_payback_tag(),
744        ))
745        .items()
746        .to_vec();
747
748        // Inject a non-zero recipient felt in the slot that must stay clear for public payback.
749        items[8] = Felt::from(1u32);
750        let err = SwapNoteStorage::try_from(items.as_slice())
751            .expect_err("public payback with a dirty recipient slot must be rejected");
752        assert_matches!(
753            err,
754            NoteError::Other { error_msg, .. }
755                if error_msg == "SWAP public payback must have recipient slots cleared".into()
756        );
757    }
758
759    #[test]
760    fn swap_tag() {
761        // Construct an ID that starts with 0xcdb1.
762        let mut fungible_faucet_id_bytes = [0; 15];
763        fungible_faucet_id_bytes[0] = 0xcd;
764        fungible_faucet_id_bytes[1] = 0xb1;
765
766        // Construct an ID that starts with 0xabec.
767        let mut non_fungible_faucet_id_bytes = [0; 15];
768        non_fungible_faucet_id_bytes[0] = 0xab;
769        non_fungible_faucet_id_bytes[1] = 0xec;
770
771        let offered_asset = Asset::Fungible(
772            FungibleAsset::new(
773                AccountId::dummy(
774                    fungible_faucet_id_bytes,
775                    AccountIdVersion::Version1,
776                    AccountType::Public,
777                    AssetCallbackFlag::Disabled,
778                ),
779                2500,
780            )
781            .unwrap(),
782        );
783
784        let requested_asset =
785            Asset::NonFungible(NonFungibleAsset::new(&NonFungibleAssetDetails::new(
786                AccountId::dummy(
787                    non_fungible_faucet_id_bytes,
788                    AccountIdVersion::Version1,
789                    AccountType::Public,
790                    AssetCallbackFlag::Disabled,
791                ),
792                vec![0xaa, 0xbb, 0xcc, 0xdd],
793            )));
794
795        // The fungible ID starts with 0xcdb1.
796        // The non fungible ID starts with 0xabec.
797        // The expected tag payload is thus 0xcdab.
798        let expected_asset_pair = 0xcdab;
799
800        let note_type = NoteType::Public;
801        let actual_tag = SwapNote::create_tag(note_type, &offered_asset, &requested_asset);
802
803        assert_eq!(actual_tag.as_u32() as u16, expected_asset_pair, "asset pair should match");
804        assert_eq!((actual_tag.as_u32() >> 31) as u8, note_type as u8, "note type should match");
805        // Check the 8 bits of the first script root byte.
806        assert_eq!(
807            (actual_tag.as_u32() >> 23) as u8,
808            SwapNote::script_root().as_bytes()[0],
809            "swap script root byte 0 should match"
810        );
811        // Extract the 7 bits of the second script root byte and shift for comparison.
812        assert_eq!(
813            ((actual_tag.as_u32() & 0b00000000_01111111_00000000_00000000) >> 16) as u8,
814            SwapNote::script_root().as_bytes()[1] >> 1,
815            "swap script root byte 1 should match with the highest bit set to zero"
816        );
817    }
818}