Skip to main content

miden_standards/note/
pswap.rs

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