Skip to main content

miden_standards/note/
pswap.rs

1use alloc::vec;
2
3use miden_protocol::account::AccountId;
4use miden_protocol::assembly::Path;
5use miden_protocol::asset::{Asset, AssetAmount, AssetCallbackFlag, FungibleAsset};
6use miden_protocol::errors::NoteError;
7use miden_protocol::note::{
8    Note,
9    NoteAssets,
10    NoteAttachment,
11    NoteAttachmentScheme,
12    NoteAttachments,
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, ZERO};
23
24use crate::StandardsLib;
25use crate::note::{P2idNoteStorage, StandardNoteAttachment};
26
27// NOTE SCRIPT
28// ================================================================================================
29
30/// Path to the PSWAP note script procedure in the standards library.
31const PSWAP_SCRIPT_PATH: &str = "::miden::standards::notes::pswap::main";
32
33// Initialize the PSWAP note script only once
34static PSWAP_SCRIPT: LazyLock<NoteScript> = LazyLock::new(|| {
35    let standards_lib = StandardsLib::default();
36    let path = Path::new(PSWAP_SCRIPT_PATH);
37    NoteScript::from_library_reference(standards_lib.as_ref(), path)
38        .expect("Standards library contains PSWAP note script procedure")
39});
40
41// PSWAP NOTE STORAGE
42// ================================================================================================
43
44/// Canonical storage representation for a PSWAP note.
45///
46/// Maps to the 7-element [`NoteStorage`] layout consumed by the on-chain MASM script:
47///
48/// | Slot | Field |
49/// |---------|-------|
50/// | `[0]` | Requested asset enable_callbacks flag |
51/// | `[1]` | Requested asset faucet ID suffix |
52/// | `[2]` | Requested asset faucet ID prefix |
53/// | `[3]` | Requested asset amount |
54/// | `[4]` | Payback note type (0 = private, 1 = public) |
55/// | `[5-6]` | Creator account ID (prefix, suffix) |
56///
57/// The payback note tag is derived at runtime from the creator account ID
58/// (via `note_tag::create_account_target` in MASM) rather than stored.
59///
60/// The PSWAP note's own tag is not stored: it lives in the note's metadata and
61/// is lifted from there by the on-chain script when a remainder note is created
62/// (the asset pair is unchanged, so the tag carries over unchanged).
63#[derive(Debug, Clone, PartialEq, Eq, bon::Builder)]
64pub struct PswapNoteStorage {
65    requested_asset: FungibleAsset,
66
67    creator_account_id: AccountId,
68
69    /// Note type of the payback note produced when the pswap is filled. Defaults to
70    /// [`NoteType::Private`] because the payback carries the fill asset and is typically
71    /// consumed directly by the creator — a private note is cheaper in fees and bandwidth
72    /// and offers the same information (the fill amount is already recorded in the
73    /// executed transaction's output).
74    #[builder(default = NoteType::Private)]
75    payback_note_type: NoteType,
76}
77
78impl PswapNoteStorage {
79    // CONSTANTS
80    // --------------------------------------------------------------------------------------------
81
82    /// Expected number of storage items for the PSWAP note.
83    pub const NUM_STORAGE_ITEMS: usize = 7;
84
85    /// Consumes the storage and returns a PSWAP [`NoteRecipient`] with the provided serial number.
86    pub fn into_recipient(self, serial_num: Word) -> NoteRecipient {
87        NoteRecipient::new(serial_num, PswapNote::script(), NoteStorage::from(self))
88    }
89
90    // PUBLIC ACCESSORS
91    // --------------------------------------------------------------------------------------------
92
93    /// Returns a reference to the requested [`FungibleAsset`].
94    pub fn requested_asset(&self) -> &FungibleAsset {
95        &self.requested_asset
96    }
97
98    /// Returns the payback note routing tag, derived from the creator's account ID.
99    pub fn payback_note_tag(&self) -> NoteTag {
100        NoteTag::with_account_target(self.creator_account_id)
101    }
102
103    /// Returns the account ID of the note creator.
104    pub fn creator_account_id(&self) -> AccountId {
105        self.creator_account_id
106    }
107
108    /// Returns the [`NoteType`] used when creating the payback note.
109    pub fn payback_note_type(&self) -> NoteType {
110        self.payback_note_type
111    }
112
113    /// Returns the faucet ID of the requested asset.
114    pub fn requested_faucet_id(&self) -> AccountId {
115        self.requested_asset.faucet_id()
116    }
117
118    /// Returns the requested token amount.
119    pub fn requested_asset_amount(&self) -> u64 {
120        self.requested_asset.amount().as_u64()
121    }
122}
123
124/// Serializes [`PswapNoteStorage`] into a 7-element [`NoteStorage`].
125impl From<PswapNoteStorage> for NoteStorage {
126    fn from(storage: PswapNoteStorage) -> Self {
127        let storage_items = vec![
128            // Requested asset (individual felts) [0-3]
129            Felt::from(storage.requested_asset.callbacks().as_u8()),
130            storage.requested_asset.faucet_id().suffix(),
131            storage.requested_asset.faucet_id().prefix().as_felt(),
132            Felt::from(storage.requested_asset.amount()),
133            // Payback note type [4]
134            Felt::from(storage.payback_note_type.as_u8()),
135            // Creator ID [5-6]
136            storage.creator_account_id.prefix().as_felt(),
137            storage.creator_account_id.suffix(),
138        ];
139        NoteStorage::new(storage_items)
140            .expect("number of storage items should not exceed max storage items")
141    }
142}
143
144/// Deserializes [`PswapNoteStorage`] from a slice of exactly 7 [`Felt`]s.
145impl TryFrom<&[Felt]> for PswapNoteStorage {
146    type Error = NoteError;
147
148    fn try_from(note_storage: &[Felt]) -> Result<Self, Self::Error> {
149        if note_storage.len() != Self::NUM_STORAGE_ITEMS {
150            return Err(NoteError::InvalidNoteStorageLength {
151                expected: Self::NUM_STORAGE_ITEMS,
152                actual: note_storage.len(),
153            });
154        }
155
156        // Reconstruct requested asset from individual felts:
157        // [0] = enable_callbacks, [1] = faucet_id_suffix, [2] = faucet_id_prefix, [3] = amount
158        let callbacks = AssetCallbackFlag::try_from(
159            u8::try_from(note_storage[0].as_canonical_u64())
160                .map_err(|_| NoteError::other("enable_callbacks exceeds u8"))?,
161        )
162        .map_err(|e| NoteError::other_with_source("failed to parse asset callback flag", e))?;
163
164        let faucet_id = AccountId::try_from_elements(note_storage[1], note_storage[2])
165            .map_err(|e| NoteError::other_with_source("failed to parse requested faucet ID", e))?;
166
167        let amount = note_storage[3].as_canonical_u64();
168        let requested_asset = FungibleAsset::new(faucet_id, amount)
169            .map_err(|e| NoteError::other_with_source("failed to create requested asset", e))?
170            .with_callbacks(callbacks);
171
172        // [4] = payback_note_type
173        let payback_note_type = NoteType::try_from(
174            u8::try_from(note_storage[4].as_canonical_u64())
175                .map_err(|_| NoteError::other("payback_note_type exceeds u8"))?,
176        )
177        .map_err(|e| NoteError::other_with_source("failed to parse payback note type", e))?;
178
179        // [5-6] = creator account ID (prefix, suffix)
180        let creator_account_id = AccountId::try_from_elements(note_storage[6], note_storage[5])
181            .map_err(|e| NoteError::other_with_source("failed to parse creator account ID", e))?;
182
183        Ok(Self {
184            requested_asset,
185            creator_account_id,
186            payback_note_type,
187        })
188    }
189}
190
191// PSWAP NOTE ATTACHMENT
192// ================================================================================================
193
194/// Typed attachment carried by both PSWAP output notes, encoded as
195/// `[amount, order_id, depth, 0]` under [`PswapNote::PSWAP_ATTACHMENT_SCHEME`].
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub struct PswapNoteAttachment {
198    amount: AssetAmount,
199    order_id: Felt,
200    depth: u32,
201}
202
203impl PswapNoteAttachment {
204    /// Creates a new [`PswapNoteAttachment`].
205    pub fn new(amount: AssetAmount, order_id: Felt, depth: u32) -> Self {
206        Self { amount, order_id, depth }
207    }
208
209    pub fn amount(&self) -> AssetAmount {
210        self.amount
211    }
212
213    pub fn order_id(&self) -> Felt {
214        self.order_id
215    }
216
217    pub fn depth(&self) -> u32 {
218        self.depth
219    }
220}
221
222impl From<PswapNoteAttachment> for NoteAttachment {
223    fn from(attachment: PswapNoteAttachment) -> Self {
224        let word = Word::from([
225            Felt::from(attachment.amount),
226            attachment.order_id,
227            Felt::from(attachment.depth),
228            ZERO,
229        ]);
230        NoteAttachment::with_word(PswapNote::PSWAP_ATTACHMENT_SCHEME, word)
231    }
232}
233
234// PSWAP NOTE
235// ================================================================================================
236
237/// A partially-fillable swap note for decentralized asset exchange.
238///
239/// A PSWAP note allows a creator to offer one fungible asset in exchange for another.
240/// Unlike a regular SWAP note, consumers may fill it partially — the unfilled portion
241/// is re-created as a remainder note with an updated serial number, while the creator
242/// receives the filled portion via a payback note.
243///
244/// The note can be consumed both in local transactions (where the consumer provides
245/// fill amounts via note_args) and in network transactions (where note_args default to
246/// `[0, 0, 0, 0]`, triggering a full fill). To route a PSWAP note to a network account,
247/// set the `attachment` to a [`NetworkAccountTarget`](crate::note::NetworkAccountTarget)
248/// via the builder.
249#[derive(Debug, Clone, bon::Builder)]
250#[builder(finish_fn(vis = "", name = build_internal))]
251pub struct PswapNote {
252    sender: AccountId,
253    storage: PswapNoteStorage,
254    serial_number: Word,
255
256    #[builder(default = NoteType::Private)]
257    note_type: NoteType,
258
259    offered_asset: FungibleAsset,
260
261    attachment: Option<NoteAttachment>,
262}
263
264impl<S: pswap_note_builder::State> PswapNoteBuilder<S>
265where
266    S: pswap_note_builder::IsComplete,
267{
268    /// Validates and builds the [`PswapNote`].
269    ///
270    /// # Errors
271    ///
272    /// Returns an error if the offered and requested assets have the same faucet ID.
273    pub fn build(self) -> Result<PswapNote, NoteError> {
274        let note = self.build_internal();
275
276        if note.offered_asset.faucet_id() == note.storage.requested_faucet_id() {
277            return Err(NoteError::other(
278                "offered and requested assets must have different faucets",
279            ));
280        }
281
282        Ok(note)
283    }
284}
285
286impl PswapNote {
287    // CONSTANTS
288    // --------------------------------------------------------------------------------------------
289
290    /// Expected number of storage items for the PSWAP note.
291    pub const NUM_STORAGE_ITEMS: usize = PswapNoteStorage::NUM_STORAGE_ITEMS;
292
293    /// Attachment scheme stamped on both PSWAP output notes (the payback P2ID and the
294    /// remainder PSWAP).
295    pub const PSWAP_ATTACHMENT_SCHEME: NoteAttachmentScheme =
296        StandardNoteAttachment::PswapAttachment.attachment_scheme();
297
298    /// Offset of the `depth` field within the [`Self::PSWAP_ATTACHMENT_SCHEME`] word.
299    const PARENT_ATTACHMENT_DEPTH_OFFSET: usize = 2;
300
301    // PUBLIC ACCESSORS
302    // --------------------------------------------------------------------------------------------
303
304    /// Returns the compiled PSWAP note script.
305    pub fn script() -> NoteScript {
306        PSWAP_SCRIPT.clone()
307    }
308
309    /// Returns the root hash of the PSWAP note script.
310    pub fn script_root() -> NoteScriptRoot {
311        PSWAP_SCRIPT.root()
312    }
313
314    /// Builds the `NOTE_ARGS` word that the PSWAP script expects when a
315    /// consumer wants to fill part of the swap:
316    ///
317    /// `[account_fill, note_fill, 0, 0]`
318    ///
319    /// - `account_fill` is the portion of the requested asset the consumer pays out of their own
320    ///   vault.
321    /// - `note_fill` is the portion sourced from another note in the same transaction (cross-swap /
322    ///   net-zero flow).
323    ///
324    /// Both values are in the requested asset's base units. In a network
325    /// transaction the kernel defaults `NOTE_ARGS` to `[0, 0, 0, 0]` and the
326    /// script falls back to a full fill, so this helper is only needed for
327    /// local transactions where the consumer is choosing the fill split.
328    ///
329    /// # Errors
330    ///
331    /// Returns an error if either value exceeds the Goldilocks field size
332    /// (i.e. cannot be represented as a [`Felt`]). In practice this cannot
333    /// happen for any amount that fits in a [`FungibleAsset`] —
334    /// `FungibleAsset::MAX_AMOUNT` is comfortably below `2^63` — but the
335    /// conversion is surfaced explicitly rather than hidden behind a panic.
336    pub fn create_args(account_fill: u64, note_fill: u64) -> Result<Word, NoteError> {
337        let account_fill = Felt::try_from(account_fill)
338            .map_err(|e| NoteError::other_with_source("account_fill is not a valid felt", e))?;
339        let note_fill = Felt::try_from(note_fill)
340            .map_err(|e| NoteError::other_with_source("note_fill is not a valid felt", e))?;
341        Ok(Word::from([account_fill, note_fill, ZERO, ZERO]))
342    }
343
344    /// Returns the account ID of the note sender.
345    pub fn sender(&self) -> AccountId {
346        self.sender
347    }
348
349    /// Returns a reference to the PSWAP note storage.
350    pub fn storage(&self) -> &PswapNoteStorage {
351        &self.storage
352    }
353
354    /// Returns the serial number of this note.
355    pub fn serial_number(&self) -> Word {
356        self.serial_number
357    }
358
359    /// Returns the note type (public or private).
360    pub fn note_type(&self) -> NoteType {
361        self.note_type
362    }
363
364    /// Returns a reference to the offered [`FungibleAsset`].
365    pub fn offered_asset(&self) -> &FungibleAsset {
366        &self.offered_asset
367    }
368
369    /// Returns a reference to the note attachments.
370    ///
371    /// For notes targeting a network account, this may contain a
372    /// [`NetworkAccountTarget`](crate::note::NetworkAccountTarget) with scheme = 2. For a
373    /// remainder PSWAP this contains the [`Self::PSWAP_ATTACHMENT_SCHEME`] word
374    /// `[amt_payout, order_id, depth, 0]`. For an original PSWAP (no prior fill),
375    /// this is typically empty.
376    pub fn attachments(&self) -> Option<&NoteAttachment> {
377        self.attachment.as_ref()
378    }
379
380    /// Returns the order_id of this lineage, equal to `serial_number()[1]`.
381    pub fn order_id(&self) -> Felt {
382        self.serial_number[1]
383    }
384
385    /// Returns the depth carried in this note's [`Self::PSWAP_ATTACHMENT_SCHEME`] attachment,
386    /// or 0 if the note has no such attachment (i.e., it is the original PSWAP, not a
387    /// remainder produced by an earlier fill).
388    ///
389    /// The next round's `current_depth` is computed as `parent_depth() + 1`, matching the
390    /// on-chain `get_current_depth` MASM procedure.
391    pub fn parent_depth(&self) -> u64 {
392        match self.attachment.as_ref() {
393            Some(att) if att.attachment_scheme() == Self::PSWAP_ATTACHMENT_SCHEME => {
394                let attachment_word = att.content().as_words()[0];
395                attachment_word[Self::PARENT_ATTACHMENT_DEPTH_OFFSET].as_canonical_u64()
396            },
397            _ => 0,
398        }
399    }
400
401    // INSTANCE METHODS
402    // --------------------------------------------------------------------------------------------
403
404    /// Executes the swap as a full fill, producing only the payback note (no remainder).
405    ///
406    /// Equivalent to calling [`Self::execute`] with `account_fill_asset` set to the full
407    /// requested amount and `note_fill_asset = None`. It also matches the on-chain
408    /// behavior when a note is consumed without explicit `note_args` (e.g. in a network
409    /// transaction, where the kernel defaults `note_args` to `[0, 0, 0, 0]` and the MASM
410    /// script falls back to a full fill).
411    pub fn execute_full_fill(&self, consumer_account_id: AccountId) -> Result<Note, NoteError> {
412        let requested_faucet_id = self.storage.requested_faucet_id();
413        let total_requested_amount = self.storage.requested_asset_amount();
414
415        let fill_asset = FungibleAsset::new(requested_faucet_id, total_requested_amount)
416            .map_err(|e| NoteError::other_with_source("failed to create full fill asset", e))?
417            .with_callbacks(self.storage.requested_asset().callbacks());
418
419        self.create_payback_note(consumer_account_id, fill_asset, total_requested_amount)
420    }
421
422    /// Executes the swap, producing the output notes for a given fill.
423    ///
424    /// `account_fill_asset` is debited from the consumer's vault; `note_fill_asset` arrives
425    /// from another note in the same transaction (cross-swap). At least one must be
426    /// provided.
427    ///
428    /// Returns `(payback_note, Option<remainder_pswap_note>)`. The remainder is
429    /// `None` when the fill equals the total requested amount (full fill).
430    ///
431    /// # Errors
432    ///
433    /// Returns an error if:
434    /// - Both assets are `None`.
435    /// - The fill amount is zero.
436    /// - The fill amount exceeds the total requested amount.
437    pub fn execute(
438        &self,
439        consumer_account_id: AccountId,
440        account_fill_asset: Option<FungibleAsset>,
441        note_fill_asset: Option<FungibleAsset>,
442    ) -> Result<(Note, Option<PswapNote>), NoteError> {
443        // Combine account fill and note fill into a single payback asset.
444        let payback_asset = match (account_fill_asset, note_fill_asset) {
445            (Some(account_fill), Some(note_fill)) => account_fill.add(note_fill).map_err(|e| {
446                NoteError::other_with_source(
447                    "failed to combine account fill and note fill assets",
448                    e,
449                )
450            })?,
451            (Some(asset), None) | (None, Some(asset)) => asset,
452            (None, None) => {
453                return Err(NoteError::other(
454                    "at least one of account_fill_asset or note_fill_asset must be provided",
455                ));
456            },
457        };
458        let fill_amount = payback_asset.amount().as_u64();
459
460        let total_offered_amount = self.offered_asset.amount().as_u64();
461        let requested_faucet_id = self.storage.requested_faucet_id();
462        let total_requested_amount = self.storage.requested_asset_amount();
463
464        // Validate fill amount
465        if fill_amount == 0 {
466            return Err(NoteError::other("Fill amount must be greater than 0"));
467        }
468        if fill_amount > total_requested_amount {
469            return Err(NoteError::other(alloc::format!(
470                "Fill amount {} exceeds requested amount {}",
471                fill_amount,
472                total_requested_amount
473            )));
474        }
475
476        // Calculate payout amounts separately for account fill and note fill, matching the
477        // MASM which calls calculate_tokens_offered_for_requested twice. This is necessary
478        // because the account fill portion goes to the consumer's vault while the total
479        // determines the remainder note's offered amount.
480        let account_fill_amount = account_fill_asset.as_ref().map_or(0, |a| a.amount().as_u64());
481        let note_fill_amount = note_fill_asset.as_ref().map_or(0, |a| a.amount().as_u64());
482        let payout_for_account_fill = Self::calculate_output_amount(
483            total_offered_amount,
484            total_requested_amount,
485            account_fill_amount,
486        )?;
487        let payout_for_note_fill = Self::calculate_output_amount(
488            total_offered_amount,
489            total_requested_amount,
490            note_fill_amount,
491        )?;
492        let offered_amount_for_fill = payout_for_account_fill + payout_for_note_fill;
493
494        let payback_note =
495            self.create_payback_note(consumer_account_id, payback_asset, fill_amount)?;
496
497        // Create remainder note if partial fill
498        let remainder = if fill_amount < total_requested_amount {
499            let remaining_offered = total_offered_amount - offered_amount_for_fill;
500            let remaining_requested = total_requested_amount - fill_amount;
501
502            let remaining_offered_asset =
503                FungibleAsset::new(self.offered_asset.faucet_id(), remaining_offered)
504                    .map_err(|e| {
505                        NoteError::other_with_source("failed to create remainder asset", e)
506                    })?
507                    .with_callbacks(self.offered_asset.callbacks());
508
509            let remaining_requested_asset =
510                FungibleAsset::new(requested_faucet_id, remaining_requested)
511                    .map_err(|e| {
512                        NoteError::other_with_source(
513                            "failed to create remaining requested asset",
514                            e,
515                        )
516                    })?
517                    .with_callbacks(self.storage.requested_asset().callbacks());
518
519            Some(self.create_remainder_pswap_note(
520                consumer_account_id,
521                remaining_offered_asset,
522                remaining_requested_asset,
523                offered_amount_for_fill,
524            )?)
525        } else {
526            None
527        };
528
529        Ok((payback_note, remainder))
530    }
531
532    /// Returns how many offered tokens a consumer receives for `fill_amount` of the
533    /// requested asset, based on this note's current offered/requested ratio.
534    ///
535    /// # Errors
536    ///
537    /// Returns an error if the calculated payout is not a valid asset amount.
538    pub fn calculate_offered_for_requested(&self, fill_amount: u64) -> Result<u64, NoteError> {
539        let total_requested = self.storage.requested_asset_amount();
540        let total_offered = self.offered_asset.amount().as_u64();
541
542        Self::calculate_output_amount(total_offered, total_requested, fill_amount)
543    }
544
545    // LINEAGE DISCOVERY
546    // --------------------------------------------------------------------------------------------
547
548    /// Reconstructs the depth-`d` payback P2ID [`Note`], so the creator can consume it as an
549    /// unauthenticated input note.
550    ///
551    /// `consumer_account_id` must be the account that consumed the parent PSWAP in round
552    /// `depth`: the MASM stamps it as the payback's metadata sender, which feeds into
553    /// [`Note::details_commitment`].
554    ///
555    /// # Errors
556    ///
557    /// Returns an error if `attachment.depth() == 0` or if the fill amount is not a valid
558    /// asset amount.
559    pub fn payback_note(
560        &self,
561        consumer_account_id: AccountId,
562        attachment: &PswapNoteAttachment,
563    ) -> Result<Note, NoteError> {
564        let depth = attachment.depth();
565        if depth == 0 {
566            return Err(NoteError::other("depth must be >= 1"));
567        }
568        let parent_depth = Felt::from(depth - 1);
569        let p2id_serial = Word::from([
570            self.serial_number[0] + ONE,
571            self.serial_number[1],
572            self.serial_number[2],
573            self.serial_number[3] + parent_depth,
574        ]);
575
576        let recipient =
577            P2idNoteStorage::new(self.storage.creator_account_id).into_recipient(p2id_serial);
578
579        let fill_asset =
580            FungibleAsset::new(self.storage.requested_faucet_id(), u64::from(attachment.amount()))
581                .map_err(|e| NoteError::other_with_source("invalid fill amount", e))?
582                .with_callbacks(self.storage.requested_asset().callbacks());
583        let assets = NoteAssets::new(vec![fill_asset.into()])?;
584
585        let metadata =
586            PartialNoteMetadata::new(consumer_account_id, self.storage.payback_note_type)
587                .with_tag(self.storage.payback_note_tag());
588
589        Ok(Note::with_attachments(
590            assets,
591            metadata,
592            recipient,
593            NoteAttachments::from(NoteAttachment::from(*attachment)),
594        ))
595    }
596
597    /// Reconstructs the depth-`d` remainder PSWAP [`Note`] in this lineage.
598    ///
599    /// Called on the original PSWAP, this returns the full Note for the remainder produced
600    /// in round `depth`. The returned Note matches the created note exactly.
601    ///
602    /// - `consumer_account_id` — the account that consumed the parent PSWAP in round `depth`, used
603    ///   as the remainder's sender.
604    /// - `attachment` — the on-chain `[amount, order_id, depth, 0]` attachment for this round,
605    ///   where `amount` is the offered-asset units paid out.
606    /// - `remaining_offered` / `remaining_requested` — the leftover amounts that survive into this
607    ///   remainder. Both are required because the price formula uses floor division, so one isn't
608    ///   derivable from the other across rounds in general.
609    ///
610    /// # Errors
611    ///
612    /// Returns an error if `attachment.depth() == 0` or if any amount is not a valid asset
613    /// amount.
614    pub fn remainder_note(
615        &self,
616        consumer_account_id: AccountId,
617        attachment: &PswapNoteAttachment,
618        remaining_offered: AssetAmount,
619        remaining_requested: AssetAmount,
620    ) -> Result<Note, NoteError> {
621        let depth = attachment.depth();
622        if depth == 0 {
623            return Err(NoteError::other("depth must be >= 1"));
624        }
625        let remainder_serial = Word::from([
626            self.serial_number[0],
627            self.serial_number[1],
628            self.serial_number[2],
629            self.serial_number[3] + Felt::from(depth),
630        ]);
631
632        let requested_asset =
633            FungibleAsset::new(self.storage.requested_faucet_id(), u64::from(remaining_requested))
634                .map_err(|e| NoteError::other_with_source("invalid remaining_requested amount", e))?
635                .with_callbacks(self.storage.requested_asset().callbacks());
636        let offered_asset =
637            FungibleAsset::new(self.offered_asset.faucet_id(), u64::from(remaining_offered))
638                .map_err(|e| NoteError::other_with_source("invalid remaining_offered amount", e))?
639                .with_callbacks(self.offered_asset.callbacks());
640
641        let new_storage = PswapNoteStorage::builder()
642            .requested_asset(requested_asset)
643            .creator_account_id(self.storage.creator_account_id)
644            .payback_note_type(self.storage.payback_note_type)
645            .build();
646        let recipient = new_storage.into_recipient(remainder_serial);
647
648        let assets = NoteAssets::new(vec![offered_asset.into()])?;
649
650        let tag = Self::create_tag(self.note_type, &offered_asset, &requested_asset);
651        let metadata = PartialNoteMetadata::new(consumer_account_id, self.note_type).with_tag(tag);
652
653        Ok(Note::with_attachments(
654            assets,
655            metadata,
656            recipient,
657            NoteAttachments::from(NoteAttachment::from(*attachment)),
658        ))
659    }
660
661    // ASSOCIATED FUNCTIONS
662    // --------------------------------------------------------------------------------------------
663
664    /// Builds the 32-bit [`NoteTag`] for a PSWAP note.
665    ///
666    /// ```text
667    /// [31..30] note_type          (2 bits)
668    /// [29..16] script_root MSBs   (14 bits)
669    /// [15..8]  offered faucet ID  (8 bits, top byte of prefix)
670    /// [7..0]   requested faucet ID (8 bits, top byte of prefix)
671    /// ```
672    pub fn create_tag(
673        note_type: NoteType,
674        offered_asset: &FungibleAsset,
675        requested_asset: &FungibleAsset,
676    ) -> NoteTag {
677        let pswap_root_bytes = Self::script().root().as_bytes();
678
679        // Construct the pswap use case ID from the 14 most significant bits of the script root.
680        // This leaves the two most significant bits zero.
681        let mut pswap_use_case_id = (pswap_root_bytes[0] as u16) << 6;
682        pswap_use_case_id |= (pswap_root_bytes[1] >> 2) as u16;
683
684        // Get bits 0..8 from the faucet IDs of both assets which will form the tag payload.
685        let offered_asset_id: u64 = offered_asset.faucet_id().prefix().into();
686        let offered_asset_tag = (offered_asset_id >> 56) as u8;
687
688        let requested_asset_id: u64 = requested_asset.faucet_id().prefix().into();
689        let requested_asset_tag = (requested_asset_id >> 56) as u8;
690
691        let asset_pair = ((offered_asset_tag as u16) << 8) | (requested_asset_tag as u16);
692
693        let tag = ((note_type as u8 as u32) << 30)
694            | ((pswap_use_case_id as u32) << 16)
695            | asset_pair as u32;
696
697        NoteTag::new(tag)
698    }
699
700    /// Computes `floor((offered_total * fill_amount) / requested_total)` via a
701    /// u128 intermediate, mirroring `u64::widening_mul` + `u128::div` on the
702    /// MASM side.
703    ///
704    /// # Errors
705    ///
706    /// Returns an error if the result does not fit in a valid [`AssetAmount`].
707    fn calculate_output_amount(
708        offered_total: u64,
709        requested_total: u64,
710        fill_amount: u64,
711    ) -> Result<u64, NoteError> {
712        let product = (offered_total as u128) * (fill_amount as u128);
713        let quotient = product / (requested_total as u128);
714        let amount = u64::try_from(quotient)
715            .map_err(|_| NoteError::other("payout quotient does not fit in u64"))?;
716        // Validate the result is a valid fungible asset amount.
717        AssetAmount::new(amount).map_err(|e| {
718            NoteError::other_with_source("payout amount exceeds max fungible asset amount", e)
719        })?;
720        Ok(amount)
721    }
722
723    /// Builds the [`NoteAttachment`] carried by both PSWAP output notes (payback and
724    /// remainder).
725    ///
726    /// `amount` is the round's transferred amount on the relevant side of the trade —
727    /// requested-asset units for the payback, offered-asset units for the remainder.
728    fn pswap_output_attachment(
729        amount: u64,
730        order_id: Felt,
731        depth: u64,
732    ) -> Result<NoteAttachment, NoteError> {
733        let amount = AssetAmount::new(amount)
734            .map_err(|e| NoteError::other_with_source("amount is not a valid asset amount", e))?;
735        let depth = u32::try_from(depth)
736            .map_err(|_| NoteError::other("PSWAP depth does not fit in u32"))?;
737        Ok(PswapNoteAttachment::new(amount, order_id, depth).into())
738    }
739
740    /// Builds a payback note (P2ID) that delivers the filled assets to the swap creator.
741    ///
742    /// The note inherits its type (public/private) from this PSWAP note and derives a
743    /// deterministic serial number by incrementing the least significant element of the
744    /// serial number (`serial[0] + 1`).
745    ///
746    /// The attachment carries `[fill_amount, order_id, current_depth, 0]` under
747    /// [`Self::PSWAP_ATTACHMENT_SCHEME`]. `current_depth` is `parent_depth + 1` — i.e.,
748    /// the round number that produced this payback (1-indexed).
749    fn create_payback_note(
750        &self,
751        consumer_account_id: AccountId,
752        payback_asset: FungibleAsset,
753        fill_amount: u64,
754    ) -> Result<Note, NoteError> {
755        let payback_note_tag = self.storage.payback_note_tag();
756        // Derive P2ID serial: increment least significant element (matching MASM add.1)
757        let p2id_serial_num = Word::from([
758            self.serial_number[0] + ONE,
759            self.serial_number[1],
760            self.serial_number[2],
761            self.serial_number[3],
762        ]);
763
764        // P2ID recipient targets the creator
765        let recipient =
766            P2idNoteStorage::new(self.storage.creator_account_id).into_recipient(p2id_serial_num);
767
768        let current_depth = self.parent_depth() + 1;
769        let attachment =
770            Self::pswap_output_attachment(fill_amount, self.order_id(), current_depth)?;
771
772        let p2id_assets = NoteAssets::new(vec![payback_asset.into()])?;
773        let p2id_metadata =
774            PartialNoteMetadata::new(consumer_account_id, self.storage.payback_note_type)
775                .with_tag(payback_note_tag);
776
777        Ok(Note::with_attachments(
778            p2id_assets,
779            p2id_metadata,
780            recipient,
781            NoteAttachments::from(attachment),
782        ))
783    }
784
785    /// Builds a remainder PSWAP note carrying the unfilled portion of the swap.
786    ///
787    /// The remainder inherits the original creator, tags, and note type, with an updated
788    /// serial number (`serial[3] + 1`).
789    ///
790    /// The attachment carries `[offered_amount_for_fill, order_id, current_depth, 0]` under
791    /// [`Self::PSWAP_ATTACHMENT_SCHEME`]. The remainder must carry this attachment so that
792    /// when *it* is later consumed as a parent, `get_current_depth` reads the right scheme
793    /// and increments depth correctly.
794    fn create_remainder_pswap_note(
795        &self,
796        consumer_account_id: AccountId,
797        remaining_offered_asset: FungibleAsset,
798        remaining_requested_asset: FungibleAsset,
799        offered_amount_for_fill: u64,
800    ) -> Result<PswapNote, NoteError> {
801        let new_storage = PswapNoteStorage::builder()
802            .requested_asset(remaining_requested_asset)
803            .creator_account_id(self.storage.creator_account_id)
804            .payback_note_type(self.storage.payback_note_type)
805            .build();
806
807        // Remainder serial: increment most significant element (matching MASM movup.3 add.1
808        // movdn.3)
809        let remainder_serial_num = Word::from([
810            self.serial_number[0],
811            self.serial_number[1],
812            self.serial_number[2],
813            self.serial_number[3] + ONE,
814        ]);
815
816        let current_depth = self.parent_depth() + 1;
817        let attachment =
818            Self::pswap_output_attachment(offered_amount_for_fill, self.order_id(), current_depth)?;
819
820        PswapNote::builder()
821            .sender(consumer_account_id)
822            .storage(new_storage)
823            .serial_number(remainder_serial_num)
824            .note_type(self.note_type)
825            .offered_asset(remaining_offered_asset)
826            .attachment(attachment)
827            .build()
828    }
829}
830
831// CONVERSIONS
832// ================================================================================================
833
834/// Converts a [`PswapNote`] into a protocol [`Note`], computing the final PSWAP tag.
835impl From<PswapNote> for Note {
836    fn from(pswap: PswapNote) -> Self {
837        let tag = PswapNote::create_tag(
838            pswap.note_type,
839            &pswap.offered_asset,
840            pswap.storage.requested_asset(),
841        );
842
843        let recipient = pswap.storage.into_recipient(pswap.serial_number);
844
845        let assets = NoteAssets::new(vec![pswap.offered_asset.into()])
846            .expect("single fungible asset should be valid");
847
848        let metadata = PartialNoteMetadata::new(pswap.sender, pswap.note_type).with_tag(tag);
849
850        let attachments = pswap.attachment.map(NoteAttachments::from).unwrap_or_default();
851
852        Note::with_attachments(assets, metadata, recipient, attachments)
853    }
854}
855
856/// Parses a protocol [`Note`] back into a [`PswapNote`] by deserializing its storage.
857impl TryFrom<&Note> for PswapNote {
858    type Error = NoteError;
859
860    fn try_from(note: &Note) -> Result<Self, Self::Error> {
861        if note.recipient().script().root() != PswapNote::script_root() {
862            return Err(NoteError::other("note script root does not match PSWAP script root"));
863        }
864
865        let storage = PswapNoteStorage::try_from(note.recipient().storage().items())?;
866
867        if note.assets().num_assets() != 1 {
868            return Err(NoteError::other("PSWAP note must have exactly one asset"));
869        }
870        let offered_asset = match note.assets().iter().next().unwrap() {
871            Asset::Fungible(fa) => *fa,
872            Asset::NonFungible(_) => {
873                return Err(NoteError::other("PSWAP note asset must be fungible"));
874            },
875        };
876
877        let attachment = match note.attachments().num_attachments() {
878            0 => None,
879            1 => {
880                Some(note.attachments().get(0).expect("length should have been validated").clone())
881            },
882            _ => return Err(NoteError::other("pswap note supports only one attachment")),
883        };
884
885        PswapNote::builder()
886            .sender(note.metadata().sender())
887            .storage(storage)
888            .serial_number(note.recipient().serial_num())
889            .note_type(note.metadata().note_type())
890            .offered_asset(offered_asset)
891            .maybe_attachment(attachment)
892            .build()
893    }
894}
895
896// TESTS
897// ================================================================================================
898
899#[cfg(test)]
900mod tests {
901    use miden_protocol::account::{AccountId, AccountIdVersion, AccountType};
902    use miden_protocol::asset::FungibleAsset;
903    use miden_protocol::crypto::rand::{FeltRng, RandomCoin};
904
905    use super::*;
906
907    // TEST HELPERS
908    // --------------------------------------------------------------------------------------------
909
910    fn dummy_faucet_id(byte: u8) -> AccountId {
911        let mut bytes = [0; 15];
912        bytes[0] = byte;
913        AccountId::dummy(bytes, AccountIdVersion::Version1, AccountType::Public)
914    }
915
916    fn dummy_creator_id() -> AccountId {
917        AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Public)
918    }
919
920    fn dummy_consumer_id() -> AccountId {
921        AccountId::dummy([2; 15], AccountIdVersion::Version1, AccountType::Public)
922    }
923
924    fn build_pswap_note(
925        offered_asset: FungibleAsset,
926        requested_asset: FungibleAsset,
927        creator_id: AccountId,
928    ) -> (PswapNote, Note) {
929        let mut rng = RandomCoin::new(Word::default());
930        let storage = PswapNoteStorage::builder()
931            .requested_asset(requested_asset)
932            .creator_account_id(creator_id)
933            .build();
934        let pswap = PswapNote::builder()
935            .sender(creator_id)
936            .storage(storage)
937            .serial_number(rng.draw_word())
938            .note_type(NoteType::Public)
939            .offered_asset(offered_asset)
940            .build()
941            .unwrap();
942        let note: Note = pswap.clone().into();
943        (pswap, note)
944    }
945
946    // TESTS
947    // --------------------------------------------------------------------------------------------
948
949    #[test]
950    fn pswap_note_creation_and_script() {
951        let creator_id = dummy_creator_id();
952        let offered_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 1000).unwrap();
953        let requested_asset = FungibleAsset::new(dummy_faucet_id(0xbb), 500).unwrap();
954
955        let (pswap, note) = build_pswap_note(offered_asset, requested_asset, creator_id);
956
957        assert_eq!(pswap.sender(), creator_id);
958        assert_eq!(pswap.note_type(), NoteType::Public);
959
960        let script = PswapNote::script();
961        assert!(Word::from(script.root()) != Word::default(), "Script root should not be zero");
962        assert_eq!(note.metadata().sender(), creator_id);
963        assert_eq!(note.metadata().note_type(), NoteType::Public);
964        assert_eq!(note.assets().num_assets(), 1);
965        assert_eq!(note.recipient().script().root(), script.root());
966        assert_eq!(
967            note.recipient().storage().num_items(),
968            PswapNoteStorage::NUM_STORAGE_ITEMS as u16,
969        );
970    }
971
972    #[test]
973    fn pswap_note_builder() {
974        let creator_id = dummy_creator_id();
975        let offered_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 1000).unwrap();
976        let requested_asset = FungibleAsset::new(dummy_faucet_id(0xbb), 500).unwrap();
977
978        let (pswap, note) = build_pswap_note(offered_asset, requested_asset, creator_id);
979
980        assert_eq!(pswap.sender(), creator_id);
981        assert_eq!(pswap.note_type(), NoteType::Public);
982        assert_eq!(note.metadata().sender(), creator_id);
983        assert_eq!(note.metadata().note_type(), NoteType::Public);
984        assert_eq!(note.assets().num_assets(), 1);
985        assert_eq!(
986            note.recipient().storage().num_items(),
987            PswapNoteStorage::NUM_STORAGE_ITEMS as u16,
988        );
989    }
990
991    #[test]
992    fn pswap_tag() {
993        let mut offered_faucet_bytes = [0; 15];
994        offered_faucet_bytes[0] = 0xcd;
995        offered_faucet_bytes[1] = 0xb1;
996
997        let mut requested_faucet_bytes = [0; 15];
998        requested_faucet_bytes[0] = 0xab;
999        requested_faucet_bytes[1] = 0xec;
1000
1001        let offered_asset = FungibleAsset::new(
1002            AccountId::dummy(offered_faucet_bytes, AccountIdVersion::Version1, AccountType::Public),
1003            100,
1004        )
1005        .unwrap();
1006        let requested_asset = FungibleAsset::new(
1007            AccountId::dummy(
1008                requested_faucet_bytes,
1009                AccountIdVersion::Version1,
1010                AccountType::Public,
1011            ),
1012            200,
1013        )
1014        .unwrap();
1015
1016        let tag = PswapNote::create_tag(NoteType::Public, &offered_asset, &requested_asset);
1017        let tag_u32 = u32::from(tag);
1018
1019        // Verify note_type bits (top 2 bits should be 10 for Public)
1020        let note_type_bits = tag_u32 >> 30;
1021        assert_eq!(note_type_bits, NoteType::Public as u32);
1022    }
1023
1024    #[test]
1025    fn calculate_output_amount() {
1026        assert_eq!(PswapNote::calculate_output_amount(100, 100, 50).unwrap(), 50); // Equal ratio
1027        assert_eq!(PswapNote::calculate_output_amount(200, 100, 50).unwrap(), 100); // 2:1 ratio
1028        assert_eq!(PswapNote::calculate_output_amount(100, 200, 50).unwrap(), 25); // 1:2 ratio
1029
1030        // Non-integer ratio (100/73)
1031        let result = PswapNote::calculate_output_amount(100, 73, 7).unwrap();
1032        assert!(result > 0, "Should produce non-zero output");
1033    }
1034
1035    #[test]
1036    fn pswap_note_storage_try_from() {
1037        let creator_id = dummy_creator_id();
1038        let requested_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 500).unwrap();
1039
1040        let storage_items = vec![
1041            Felt::from(requested_asset.callbacks().as_u8()),
1042            requested_asset.faucet_id().suffix(),
1043            requested_asset.faucet_id().prefix().as_felt(),
1044            Felt::from(requested_asset.amount()),
1045            Felt::from(NoteType::Private.as_u8()), // payback_note_type
1046            creator_id.prefix().as_felt(),
1047            creator_id.suffix(),
1048        ];
1049
1050        let parsed = PswapNoteStorage::try_from(storage_items.as_slice()).unwrap();
1051        assert_eq!(parsed.creator_account_id(), creator_id);
1052        assert_eq!(parsed.requested_asset_amount(), 500);
1053    }
1054
1055    #[test]
1056    fn pswap_note_storage_roundtrip() {
1057        let creator_id = dummy_creator_id();
1058        let requested_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 500).unwrap();
1059
1060        let storage = PswapNoteStorage::builder()
1061            .requested_asset(requested_asset)
1062            .creator_account_id(creator_id)
1063            .build();
1064
1065        let note_storage = NoteStorage::from(storage.clone());
1066        let parsed = PswapNoteStorage::try_from(note_storage.items()).unwrap();
1067
1068        assert_eq!(parsed.creator_account_id(), creator_id);
1069        assert_eq!(parsed.requested_asset_amount(), 500);
1070    }
1071
1072    /// Consumer supplies both an account fill and a note fill, and the sum is below
1073    /// the requested amount → `execute` must combine them into a single payback note
1074    /// carrying account_fill+note_fill of the requested asset and emit a remainder
1075    /// pswap note for the unfilled portion.
1076    #[test]
1077    fn pswap_execute_combined_account_fill_and_note_fill_partial_fill() {
1078        let creator_id = dummy_creator_id();
1079        let consumer_id = dummy_consumer_id();
1080        let offered_faucet = dummy_faucet_id(0xaa);
1081        let requested_faucet = dummy_faucet_id(0xbb);
1082
1083        // Offer 100 offered, request 50 requested → 2:1 ratio.
1084        let offered_asset = FungibleAsset::new(offered_faucet, 100).unwrap();
1085        let requested_asset = FungibleAsset::new(requested_faucet, 50).unwrap();
1086        let (pswap, _) = build_pswap_note(offered_asset, requested_asset, creator_id);
1087
1088        // Account fill = 10, note fill = 20 → total fill = 30 (< 50, so partial).
1089        let account_fill = FungibleAsset::new(requested_faucet, 10).unwrap();
1090        let note_fill = FungibleAsset::new(requested_faucet, 20).unwrap();
1091
1092        let (payback, remainder) =
1093            pswap.execute(consumer_id, Some(account_fill), Some(note_fill)).unwrap();
1094
1095        // Payback note must carry the combined 30 of requested asset.
1096        assert_eq!(payback.assets().num_assets(), 1);
1097        let payback_asset = payback.assets().iter().next().unwrap();
1098        let Asset::Fungible(fa) = payback_asset else {
1099            panic!("expected fungible payback asset");
1100        };
1101        assert_eq!(fa.faucet_id(), requested_faucet);
1102        assert_eq!(fa.amount().as_u64(), 30);
1103
1104        // Remainder must exist with the unfilled 50 - 30 = 20 of requested, and the
1105        // offered amount reduced proportionally (100 - 30*2 = 40).
1106        let remainder = remainder.expect("partial fill should produce remainder");
1107        assert_eq!(remainder.storage().requested_asset_amount(), 20);
1108        assert_eq!(remainder.offered_asset().amount().as_u64(), 40);
1109        assert_eq!(remainder.storage().creator_account_id(), creator_id);
1110    }
1111
1112    /// Consumer supplies both an account fill and a note fill, and the sum exactly
1113    /// matches the requested amount → `execute` must produce a single payback note for
1114    /// the full amount and no remainder.
1115    #[test]
1116    fn pswap_execute_combined_account_fill_and_note_fill_full_fill() {
1117        let creator_id = dummy_creator_id();
1118        let consumer_id = dummy_consumer_id();
1119        let offered_faucet = dummy_faucet_id(0xaa);
1120        let requested_faucet = dummy_faucet_id(0xbb);
1121
1122        let offered_asset = FungibleAsset::new(offered_faucet, 100).unwrap();
1123        let requested_asset = FungibleAsset::new(requested_faucet, 50).unwrap();
1124        let (pswap, _) = build_pswap_note(offered_asset, requested_asset, creator_id);
1125
1126        // Account fill = 30, note fill = 20 → total fill = 50 (exactly requested).
1127        let account_fill = FungibleAsset::new(requested_faucet, 30).unwrap();
1128        let note_fill = FungibleAsset::new(requested_faucet, 20).unwrap();
1129
1130        let (payback, remainder) =
1131            pswap.execute(consumer_id, Some(account_fill), Some(note_fill)).unwrap();
1132
1133        // Payback note must carry the full 50 of requested asset.
1134        assert_eq!(payback.assets().num_assets(), 1);
1135        let payback_asset = payback.assets().iter().next().unwrap();
1136        let Asset::Fungible(fa) = payback_asset else {
1137            panic!("expected fungible payback asset");
1138        };
1139        assert_eq!(fa.faucet_id(), requested_faucet);
1140        assert_eq!(fa.amount().as_u64(), 50);
1141
1142        // Full fill → no remainder note.
1143        assert!(remainder.is_none(), "full fill must not produce a remainder");
1144    }
1145
1146    /// Regression for the silent `AssetCallbackFlag` drop: when the PSWAP's requested or
1147    /// offered asset carries `Enabled` callbacks, the on-chain MASM preserves that flag
1148    /// on every output note's asset. The Rust-side `execute`, `payback_note`, and
1149    /// `remainder_note` must do the same — otherwise the reconstructed `Note::details_commitment`
1150    /// diverges from the on-chain leaf and the unauthenticated consume path fails.
1151    #[test]
1152    fn pswap_output_assets_preserve_callback_flag() {
1153        let creator_id = dummy_creator_id();
1154        let consumer_id = dummy_consumer_id();
1155        let offered_faucet = dummy_faucet_id(0xaa);
1156        let requested_faucet = dummy_faucet_id(0xbb);
1157
1158        let offered_asset = FungibleAsset::new(offered_faucet, 100)
1159            .unwrap()
1160            .with_callbacks(AssetCallbackFlag::Enabled);
1161        let requested_asset = FungibleAsset::new(requested_faucet, 50)
1162            .unwrap()
1163            .with_callbacks(AssetCallbackFlag::Enabled);
1164        let (pswap, _) = build_pswap_note(offered_asset, requested_asset, creator_id);
1165
1166        // --- execute() (partial fill) ---
1167        let account_fill = FungibleAsset::new(requested_faucet, 20)
1168            .unwrap()
1169            .with_callbacks(AssetCallbackFlag::Enabled);
1170        let (payback, remainder) = pswap.execute(consumer_id, Some(account_fill), None).unwrap();
1171
1172        let Asset::Fungible(fa) = payback.assets().iter().next().unwrap() else {
1173            panic!("expected fungible payback asset");
1174        };
1175        assert_eq!(fa.callbacks(), AssetCallbackFlag::Enabled);
1176
1177        let remainder = remainder.expect("partial fill should produce remainder");
1178        assert_eq!(
1179            remainder.offered_asset().callbacks(),
1180            AssetCallbackFlag::Enabled,
1181            "remainder offered asset must inherit callbacks",
1182        );
1183        assert_eq!(
1184            remainder.storage().requested_asset().callbacks(),
1185            AssetCallbackFlag::Enabled,
1186            "remainder storage's requested asset must inherit callbacks",
1187        );
1188
1189        // --- payback_note() reconstruction ---
1190        let payback_attachment =
1191            PswapNoteAttachment::new(AssetAmount::new(20).unwrap(), pswap.order_id(), 1);
1192        let reconstructed_payback = pswap.payback_note(consumer_id, &payback_attachment).unwrap();
1193        let Asset::Fungible(fa) = reconstructed_payback.assets().iter().next().unwrap() else {
1194            panic!("expected fungible payback asset");
1195        };
1196        assert_eq!(
1197            fa.callbacks(),
1198            AssetCallbackFlag::Enabled,
1199            "payback_note must preserve requested asset's callback flag",
1200        );
1201
1202        // --- remainder_note() reconstruction ---
1203        let remainder_attachment =
1204            PswapNoteAttachment::new(AssetAmount::new(40).unwrap(), pswap.order_id(), 1);
1205        let reconstructed_remainder = pswap
1206            .remainder_note(
1207                consumer_id,
1208                &remainder_attachment,
1209                AssetAmount::new(60).unwrap(),
1210                AssetAmount::new(30).unwrap(),
1211            )
1212            .unwrap();
1213        let Asset::Fungible(fa) = reconstructed_remainder.assets().iter().next().unwrap() else {
1214            panic!("expected fungible remainder asset");
1215        };
1216        assert_eq!(
1217            fa.callbacks(),
1218            AssetCallbackFlag::Enabled,
1219            "remainder_note must preserve offered asset's callback flag",
1220        );
1221    }
1222}