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