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    /// Attachment scheme stamped on both PSWAP output notes (the payback P2ID and the
353    /// remainder PSWAP).
354    pub const PSWAP_ATTACHMENT_SCHEME: NoteAttachmentScheme =
355        StandardNoteAttachment::PswapAttachment.attachment_scheme();
356
357    /// Offset of the `depth` field within the [`Self::PSWAP_ATTACHMENT_SCHEME`] word.
358    const PARENT_ATTACHMENT_DEPTH_OFFSET: usize = 2;
359
360    // PUBLIC ACCESSORS
361    // --------------------------------------------------------------------------------------------
362
363    /// Returns the compiled PSWAP note script.
364    pub fn script() -> NoteScript {
365        PSWAP_SCRIPT.clone()
366    }
367
368    /// Returns the root hash of the PSWAP note script.
369    pub fn script_root() -> NoteScriptRoot {
370        PSWAP_SCRIPT.root()
371    }
372
373    /// Builds the `NOTE_ARGS` word that the PSWAP script expects when a
374    /// consumer wants to fill part of the swap:
375    ///
376    /// `[account_fill, note_fill, 0, 0]`
377    ///
378    /// - `account_fill` is the portion of the requested asset the consumer pays out of their own
379    ///   vault.
380    /// - `note_fill` is the portion sourced from another note in the same transaction (cross-swap /
381    ///   net-zero flow).
382    ///
383    /// Both values are in the requested asset's base units. In a network
384    /// transaction the kernel defaults `NOTE_ARGS` to `[0, 0, 0, 0]` and the
385    /// script falls back to a full fill, so this helper is only needed for
386    /// local transactions where the consumer is choosing the fill split.
387    ///
388    /// # Errors
389    ///
390    /// Returns an error if either value exceeds the Goldilocks field size
391    /// (i.e. cannot be represented as a [`Felt`]). In practice this cannot
392    /// happen for any amount that fits in a [`FungibleAsset`] —
393    /// `FungibleAsset::MAX_AMOUNT` is comfortably below `2^63` — but the
394    /// conversion is surfaced explicitly rather than hidden behind a panic.
395    pub fn create_args(account_fill: u64, note_fill: u64) -> Result<Word, NoteError> {
396        let account_fill = Felt::try_from(account_fill)
397            .map_err(|e| NoteError::other_with_source("account_fill is not a valid felt", e))?;
398        let note_fill = Felt::try_from(note_fill)
399            .map_err(|e| NoteError::other_with_source("note_fill is not a valid felt", e))?;
400        Ok(Word::from([account_fill, note_fill, ZERO, ZERO]))
401    }
402
403    /// Returns the account ID of the note sender.
404    pub fn sender(&self) -> AccountId {
405        self.sender
406    }
407
408    /// Returns a reference to the PSWAP note storage.
409    pub fn storage(&self) -> &PswapNoteStorage {
410        &self.storage
411    }
412
413    /// Returns the serial number of this note.
414    pub fn serial_number(&self) -> Word {
415        self.serial_number
416    }
417
418    /// Returns the note type (public or private).
419    pub fn note_type(&self) -> NoteType {
420        self.note_type
421    }
422
423    /// Returns a reference to the offered [`FungibleAsset`].
424    pub fn offered_asset(&self) -> &FungibleAsset {
425        &self.offered_asset
426    }
427
428    /// Returns a reference to the note attachments.
429    ///
430    /// For notes targeting a network account, this may contain a
431    /// [`NetworkAccountTarget`](crate::note::NetworkAccountTarget) with scheme = 2. For a
432    /// remainder PSWAP this contains the [`Self::PSWAP_ATTACHMENT_SCHEME`] word
433    /// `[amt_payout, order_id, depth, 0]`. For an original PSWAP (no prior fill),
434    /// this is typically empty.
435    pub fn attachments(&self) -> Option<&NoteAttachment> {
436        self.attachment.as_ref()
437    }
438
439    /// Returns the order_id of this lineage, equal to `serial_number()[1]`.
440    pub fn order_id(&self) -> Felt {
441        self.serial_number[1]
442    }
443
444    /// Returns the depth carried in this note's [`Self::PSWAP_ATTACHMENT_SCHEME`] attachment,
445    /// or 0 if the note has no such attachment (i.e., it is the original PSWAP, not a
446    /// remainder produced by an earlier fill).
447    ///
448    /// The next round's `current_depth` is computed as `parent_depth() + 1`, matching the
449    /// on-chain `get_current_depth` MASM procedure.
450    pub fn parent_depth(&self) -> u32 {
451        self.attachment
452            .as_ref()
453            .and_then(|attachment| PswapNoteAttachment::try_from(attachment).ok())
454            .map_or(0, |attachment| attachment.depth())
455    }
456
457    // INSTANCE METHODS
458    // --------------------------------------------------------------------------------------------
459
460    /// Executes the swap as a full fill, producing only the payback note (no remainder).
461    ///
462    /// Equivalent to calling [`Self::execute`] with `account_fill_asset` set to the full
463    /// requested amount and `note_fill_asset = None`. It also matches the on-chain
464    /// behavior when a note is consumed without explicit `note_args` (e.g. in a network
465    /// transaction, where the kernel defaults `note_args` to `[0, 0, 0, 0]` and the MASM
466    /// script falls back to a full fill).
467    pub fn execute_full_fill(&self, consumer_account_id: AccountId) -> Result<Note, NoteError> {
468        let requested_faucet_id = self.storage.requested_faucet_id();
469        let min_requested_amount = self.storage.min_requested_amount();
470
471        let fill_asset = FungibleAsset::new(requested_faucet_id, min_requested_amount)
472            .map_err(|e| NoteError::other_with_source("failed to create full fill asset", e))?;
473
474        self.create_payback_note(consumer_account_id, fill_asset, min_requested_amount)
475    }
476
477    /// Executes the swap, producing the output notes for a given fill.
478    ///
479    /// `account_fill_asset` is debited from the consumer's vault; `note_fill_asset` arrives
480    /// from another note in the same transaction (cross-swap). At least one must be
481    /// provided.
482    ///
483    /// Returns `(payback_note, Option<remainder_pswap_note>)`. The remainder is
484    /// `None` when the fill is at least `min_requested_amount` (full fill or over-fill).
485    ///
486    /// # Errors
487    ///
488    /// Returns an error if:
489    /// - Both assets are `None`.
490    /// - The fill amount is zero.
491    /// - The combined fill amount overflows or exceeds the maximum fungible asset amount.
492    pub fn execute(
493        &self,
494        consumer_account_id: AccountId,
495        account_fill_asset: Option<FungibleAsset>,
496        note_fill_asset: Option<FungibleAsset>,
497    ) -> Result<(Note, Option<PswapNote>), NoteError> {
498        // Combine account fill and note fill into a single payback asset.
499        let payback_asset = match (account_fill_asset, note_fill_asset) {
500            (Some(account_fill), Some(note_fill)) => account_fill.add(note_fill).map_err(|e| {
501                NoteError::other_with_source(
502                    "failed to combine account fill and note fill assets",
503                    e,
504                )
505            })?,
506            (Some(asset), None) | (None, Some(asset)) => asset,
507            (None, None) => {
508                return Err(NoteError::other(
509                    "at least one of account_fill_asset or note_fill_asset must be provided",
510                ));
511            },
512        };
513        let fill_amount = payback_asset.amount().as_u64();
514
515        let total_offered_amount = self.offered_asset.amount().as_u64();
516        let requested_faucet_id = self.storage.requested_faucet_id();
517        let min_requested_amount = self.storage.min_requested_amount();
518
519        // Validate fill amount
520        if fill_amount == 0 {
521            return Err(NoteError::other("Fill amount must be greater than 0"));
522        }
523
524        let account_fill_amount = account_fill_asset.as_ref().map_or(0, |a| a.amount().as_u64());
525        let note_fill_amount = note_fill_asset.as_ref().map_or(0, |a| a.amount().as_u64());
526
527        // Enforce the per-fill floor, mirroring the MASM `execute_pswap` guard. The effective floor
528        // is clamped to `min(min_fill_step, min_requested_amount)` so a remainder whose requested
529        // amount has shrunk below `min_fill_step` stays fillable in full. `min_fill_step == 0`
530        // disables the floor.
531        let effective_floor = self.storage.min_fill_step().as_u64().min(min_requested_amount);
532        if fill_amount < effective_floor {
533            return Err(NoteError::other("PSWAP fill amount is below the minimum fill step"));
534        }
535
536        // `min_requested_amount` is a floor, not an exact target: each fill's share is computed
537        // against `fill_reference = max(fill_amount, min_requested_amount)`. At or below the
538        // minimum this is `min_requested_amount` (proportional, leaving a remainder); for an
539        // over-fill it is the fill itself, so the whole offered side is paid out and no remainder
540        // is created.
541        let fill_reference = fill_amount.max(min_requested_amount);
542
543        // Calculate payout amounts separately for account fill and note fill, matching the MASM
544        // which calls calculate_output_amount twice: the account fill portion is credited to the
545        // consumer's vault while the total determines the remainder note's offered amount.
546        let payout_for_account_fill = Self::calculate_output_amount(
547            total_offered_amount,
548            fill_reference,
549            account_fill_amount,
550        )?;
551        let payout_for_note_fill =
552            Self::calculate_output_amount(total_offered_amount, fill_reference, note_fill_amount)?;
553        let offered_amount_for_fill = payout_for_account_fill + payout_for_note_fill;
554
555        let payback_note =
556            self.create_payback_note(consumer_account_id, payback_asset, fill_amount)?;
557
558        // Create remainder note if partial fill
559        let remainder = if fill_amount < min_requested_amount {
560            let remaining_offered = total_offered_amount - offered_amount_for_fill;
561            let remaining_requested = min_requested_amount - fill_amount;
562
563            let remaining_offered_asset =
564                FungibleAsset::new(self.offered_asset.faucet_id(), remaining_offered).map_err(
565                    |e| NoteError::other_with_source("failed to create remainder asset", e),
566                )?;
567
568            let remaining_min_requested_asset =
569                FungibleAsset::new(requested_faucet_id, remaining_requested).map_err(|e| {
570                    NoteError::other_with_source("failed to create remaining requested asset", e)
571                })?;
572
573            Some(self.create_remainder_pswap_note(
574                consumer_account_id,
575                remaining_offered_asset,
576                remaining_min_requested_asset,
577                offered_amount_for_fill,
578            )?)
579        } else {
580            None
581        };
582
583        Ok((payback_note, remainder))
584    }
585
586    /// Returns how many offered tokens a consumer receives for `fill_amount` of the
587    /// requested asset, based on this note's current offered/requested ratio.
588    ///
589    /// `min_requested_amount` is a floor, not an exact price: a `fill_amount` at or above it
590    /// returns the entire offered amount. (The divisor is `max(fill_amount, min_requested)`, so
591    /// the payout ratio never exceeds 1 — see [`Self::execute`].)
592    ///
593    /// # Errors
594    ///
595    /// Returns an error if the calculated payout is not a valid asset amount.
596    pub fn calculate_offered_for_requested(&self, fill_amount: u64) -> Result<u64, NoteError> {
597        let min_requested = self.storage.min_requested_amount();
598        let total_offered = self.offered_asset.amount().as_u64();
599
600        let fill_reference = fill_amount.max(min_requested);
601        Self::calculate_output_amount(total_offered, fill_reference, fill_amount)
602    }
603
604    // LINEAGE DISCOVERY
605    // --------------------------------------------------------------------------------------------
606
607    /// Returns the number of fill rounds between this note and the round `attachment` was
608    /// stamped in.
609    ///
610    /// # Errors
611    ///
612    /// Returns an error if the attachment was not stamped in a round after this note.
613    fn rounds_since(&self, attachment: &PswapNoteAttachment) -> Result<u32, NoteError> {
614        attachment
615            .depth()
616            .checked_sub(self.parent_depth())
617            .filter(|rounds| *rounds > 0)
618            .ok_or_else(|| {
619                NoteError::other("attachment depth must be greater than this note's depth")
620            })
621    }
622
623    /// Reconstructs the depth-`d` payback P2ID [`Note`], so the creator can consume it as an
624    /// unauthenticated input note.
625    ///
626    /// `consumer_account_id` must be the account that consumed the parent PSWAP in round
627    /// `depth`: the MASM stamps it as the payback's metadata sender, which feeds into
628    /// [`Note::details_commitment`].
629    ///
630    /// # Errors
631    ///
632    /// Returns an error if the attachment's depth is not greater than this note's depth,
633    /// or if the attachment's fill amount is not a valid fungible asset amount.
634    pub fn payback_note(
635        &self,
636        consumer_account_id: AccountId,
637        attachment: &PswapNoteAttachment,
638    ) -> Result<Note, NoteError> {
639        // Payback serial = consumed PSWAP's serial (last element bumped `rounds - 1`
640        // times from this note's) with the first element incremented by one.
641        let rounds = self.rounds_since(attachment)?;
642        let p2id_serial = Word::from([
643            self.serial_number[0] + ONE,
644            self.serial_number[1],
645            self.serial_number[2],
646            self.serial_number[3] + Felt::from(rounds - 1),
647        ]);
648
649        let recipient =
650            P2idNoteStorage::new(self.storage.creator_account_id).into_recipient(p2id_serial);
651
652        let fill_asset =
653            FungibleAsset::new(self.storage.requested_faucet_id(), u64::from(attachment.amount()))
654                .map_err(|e| NoteError::other_with_source("invalid fill amount", e))?;
655        let assets = NoteAssets::new(vec![fill_asset.into()])?;
656
657        let metadata =
658            PartialNoteMetadata::new(consumer_account_id, self.storage.payback_note_type)
659                .with_tag(self.storage.payback_note_tag());
660
661        Ok(Note::with_attachments(
662            assets,
663            metadata,
664            recipient,
665            NoteAttachments::from(NoteAttachment::from(*attachment)),
666        ))
667    }
668
669    /// Reconstructs the depth-`d` remainder PSWAP [`Note`] in this lineage.
670    ///
671    /// Called on the original PSWAP, this returns the full Note for the remainder produced
672    /// in round `depth`. The returned Note matches the created note exactly.
673    ///
674    /// - `consumer_account_id` — the account that consumed the parent PSWAP in round `depth`, used
675    ///   as the remainder's sender.
676    /// - `attachment` — the on-chain `[amount, order_id, depth, 0]` attachment for this round,
677    ///   where `amount` is the offered-asset units paid out.
678    /// - `remaining_offered` / `remaining_requested` — the leftover amounts that survive into this
679    ///   remainder. Both are required because the price formula uses floor division, so one isn't
680    ///   derivable from the other across rounds in general.
681    ///
682    /// # Errors
683    ///
684    /// Returns an error if `attachment` was not stamped in a round after this note, or if any
685    /// amount is not a valid asset amount.
686    pub fn remainder_note(
687        &self,
688        consumer_account_id: AccountId,
689        attachment: &PswapNoteAttachment,
690        remaining_offered: AssetAmount,
691        remaining_requested: AssetAmount,
692    ) -> Result<Note, NoteError> {
693        // Every round bumps the remainder's serial once, so the offset is the round distance.
694        let rounds = self.rounds_since(attachment)?;
695        let remainder_serial = Word::from([
696            self.serial_number[0],
697            self.serial_number[1],
698            self.serial_number[2],
699            self.serial_number[3] + Felt::from(rounds),
700        ]);
701
702        let min_requested_asset =
703            FungibleAsset::new(self.storage.requested_faucet_id(), u64::from(remaining_requested))
704                .map_err(|e| {
705                    NoteError::other_with_source("invalid remaining_requested amount", e)
706                })?;
707        let offered_asset =
708            FungibleAsset::new(self.offered_asset.faucet_id(), u64::from(remaining_offered))
709                .map_err(|e| NoteError::other_with_source("invalid remaining_offered amount", e))?;
710
711        let new_storage = PswapNoteStorage::builder()
712            .min_requested_asset(min_requested_asset)
713            .creator_account_id(self.storage.creator_account_id)
714            .payback_note_type(self.storage.payback_note_type)
715            .min_fill_step(self.storage.min_fill_step())
716            .build();
717        let recipient = new_storage.into_recipient(remainder_serial);
718
719        let assets = NoteAssets::new(vec![offered_asset.into()])?;
720
721        let tag = Self::create_tag(self.note_type, &offered_asset, &min_requested_asset);
722        let metadata = PartialNoteMetadata::new(consumer_account_id, self.note_type).with_tag(tag);
723
724        Ok(Note::with_attachments(
725            assets,
726            metadata,
727            recipient,
728            NoteAttachments::from(NoteAttachment::from(*attachment)),
729        ))
730    }
731
732    // ASSOCIATED FUNCTIONS
733    // --------------------------------------------------------------------------------------------
734
735    /// Builds the 32-bit [`NoteTag`] for a PSWAP note.
736    ///
737    /// ```text
738    /// [31..30] note_type          (2 bits)
739    /// [29..16] script_root MSBs   (14 bits)
740    /// [15..8]  offered faucet ID  (8 bits, top byte of prefix)
741    /// [7..0]   requested faucet ID (8 bits, top byte of prefix)
742    /// ```
743    pub fn create_tag(
744        note_type: NoteType,
745        offered_asset: &FungibleAsset,
746        min_requested_asset: &FungibleAsset,
747    ) -> NoteTag {
748        let pswap_root_bytes = Self::script().root().as_bytes();
749
750        // Construct the pswap use case ID from the 14 most significant bits of the script root.
751        // This leaves the two most significant bits zero.
752        let mut pswap_use_case_id = (pswap_root_bytes[0] as u16) << 6;
753        pswap_use_case_id |= (pswap_root_bytes[1] >> 2) as u16;
754
755        // Get bits 0..8 from the faucet IDs of both assets which will form the tag payload.
756        let offered_asset_id: u64 = offered_asset.faucet_id().prefix().into();
757        let offered_asset_tag = (offered_asset_id >> 56) as u8;
758
759        let min_requested_asset_id: u64 = min_requested_asset.faucet_id().prefix().into();
760        let min_requested_asset_tag = (min_requested_asset_id >> 56) as u8;
761
762        let asset_pair = ((offered_asset_tag as u16) << 8) | (min_requested_asset_tag as u16);
763
764        let tag = ((note_type as u8 as u32) << 30)
765            | ((pswap_use_case_id as u32) << 16)
766            | asset_pair as u32;
767
768        NoteTag::new(tag)
769    }
770
771    /// Computes a fill's proportional share of the offered tokens:
772    /// `floor((offered_total * fill_amount) / fill_reference)`, computed via a u128 intermediate.
773    ///
774    /// The caller passes `fill_reference = max(total_fill, min_requested_amount)`, so for an
775    /// over-fill the shares scale by the actual fill rather than `min_requested_amount` (see
776    /// [`Self::execute`]).
777    ///
778    /// # Errors
779    ///
780    /// Returns an error if the result does not fit in a valid [`AssetAmount`].
781    fn calculate_output_amount(
782        offered_total: u64,
783        fill_reference: u64,
784        fill_amount: u64,
785    ) -> Result<u64, NoteError> {
786        let product = (offered_total as u128) * (fill_amount as u128);
787        let quotient = product / (fill_reference as u128);
788        let amount = u64::try_from(quotient)
789            .map_err(|_| NoteError::other("payout quotient does not fit in u64"))?;
790        // Validate the result is a valid fungible asset amount.
791        AssetAmount::new(amount).map_err(|e| {
792            NoteError::other_with_source("payout amount exceeds max fungible asset amount", e)
793        })?;
794        Ok(amount)
795    }
796
797    /// Builds the [`NoteAttachment`] carried by both PSWAP output notes (payback and
798    /// remainder).
799    ///
800    /// `amount` is the round's transferred amount on the relevant side of the trade —
801    /// requested-asset units for the payback, offered-asset units for the remainder.
802    fn pswap_output_attachment(
803        amount: u64,
804        order_id: Felt,
805        depth: u64,
806    ) -> Result<NoteAttachment, NoteError> {
807        let amount = AssetAmount::new(amount)
808            .map_err(|e| NoteError::other_with_source("amount is not a valid asset amount", e))?;
809        let depth = u32::try_from(depth)
810            .map_err(|_| NoteError::other("PSWAP depth does not fit in u32"))?;
811        Ok(PswapNoteAttachment::new(amount, order_id, depth).into())
812    }
813
814    /// Builds a payback note (P2ID) that delivers the filled assets to the swap creator.
815    ///
816    /// The note inherits its type (public/private) from this PSWAP note and derives a
817    /// deterministic serial number by incrementing the least significant element of the
818    /// serial number (`serial[0] + 1`).
819    ///
820    /// The attachment carries `[fill_amount, order_id, current_depth, 0]` under
821    /// [`Self::PSWAP_ATTACHMENT_SCHEME`]. `current_depth` is `parent_depth + 1` — i.e.,
822    /// the round number that produced this payback (1-indexed).
823    fn create_payback_note(
824        &self,
825        consumer_account_id: AccountId,
826        payback_asset: FungibleAsset,
827        fill_amount: u64,
828    ) -> Result<Note, NoteError> {
829        let payback_note_tag = self.storage.payback_note_tag();
830        // Derive P2ID serial: increment least significant element (matching MASM add.1)
831        let p2id_serial_num = Word::from([
832            self.serial_number[0] + ONE,
833            self.serial_number[1],
834            self.serial_number[2],
835            self.serial_number[3],
836        ]);
837
838        // P2ID recipient targets the creator
839        let recipient =
840            P2idNoteStorage::new(self.storage.creator_account_id).into_recipient(p2id_serial_num);
841
842        let current_depth = u64::from(self.parent_depth()) + 1;
843        let attachment =
844            Self::pswap_output_attachment(fill_amount, self.order_id(), current_depth)?;
845
846        let p2id_assets = NoteAssets::new(vec![payback_asset.into()])?;
847        let p2id_metadata =
848            PartialNoteMetadata::new(consumer_account_id, self.storage.payback_note_type)
849                .with_tag(payback_note_tag);
850
851        Ok(Note::with_attachments(
852            p2id_assets,
853            p2id_metadata,
854            recipient,
855            NoteAttachments::from(attachment),
856        ))
857    }
858
859    /// Builds a remainder PSWAP note carrying the unfilled portion of the swap.
860    ///
861    /// The remainder inherits the original creator, tags, and note type, with an updated
862    /// serial number (`serial[3] + 1`).
863    ///
864    /// The attachment carries `[offered_amount_for_fill, order_id, current_depth, 0]` under
865    /// [`Self::PSWAP_ATTACHMENT_SCHEME`]. The remainder must carry this attachment so that
866    /// when *it* is later consumed as a parent, `get_current_depth` reads the right scheme
867    /// and increments depth correctly.
868    fn create_remainder_pswap_note(
869        &self,
870        consumer_account_id: AccountId,
871        remaining_offered_asset: FungibleAsset,
872        remaining_min_requested_asset: FungibleAsset,
873        offered_amount_for_fill: u64,
874    ) -> Result<PswapNote, NoteError> {
875        let new_storage = PswapNoteStorage::builder()
876            .min_requested_asset(remaining_min_requested_asset)
877            .creator_account_id(self.storage.creator_account_id)
878            .payback_note_type(self.storage.payback_note_type)
879            .min_fill_step(self.storage.min_fill_step())
880            .build();
881
882        // Remainder serial: increment most significant element (matching MASM movup.3 add.1
883        // movdn.3)
884        let remainder_serial_num = Word::from([
885            self.serial_number[0],
886            self.serial_number[1],
887            self.serial_number[2],
888            self.serial_number[3] + ONE,
889        ]);
890
891        let current_depth = u64::from(self.parent_depth()) + 1;
892        let attachment =
893            Self::pswap_output_attachment(offered_amount_for_fill, self.order_id(), current_depth)?;
894
895        PswapNote::builder()
896            .sender(consumer_account_id)
897            .storage(new_storage)
898            .serial_number(remainder_serial_num)
899            .note_type(self.note_type)
900            .offered_asset(remaining_offered_asset)
901            .attachment(attachment)
902            .build()
903    }
904}
905
906// CONVERSIONS
907// ================================================================================================
908
909/// Converts a [`PswapNote`] into a protocol [`Note`], computing the final PSWAP tag.
910impl From<PswapNote> for Note {
911    fn from(pswap: PswapNote) -> Self {
912        let tag = PswapNote::create_tag(
913            pswap.note_type,
914            &pswap.offered_asset,
915            pswap.storage.min_requested_asset(),
916        );
917
918        let recipient = pswap.storage.into_recipient(pswap.serial_number);
919
920        let assets = NoteAssets::new(vec![pswap.offered_asset.into()])
921            .expect("single fungible asset should be valid");
922
923        let metadata = PartialNoteMetadata::new(pswap.sender, pswap.note_type).with_tag(tag);
924
925        let attachments = pswap.attachment.map(NoteAttachments::from).unwrap_or_default();
926
927        Note::with_attachments(assets, metadata, recipient, attachments)
928    }
929}
930
931/// Parses a protocol [`Note`] back into a [`PswapNote`] by deserializing its storage.
932impl TryFrom<&Note> for PswapNote {
933    type Error = NoteError;
934
935    fn try_from(note: &Note) -> Result<Self, Self::Error> {
936        if note.recipient().script().root() != PswapNote::script_root() {
937            return Err(NoteError::other("note script root does not match PSWAP script root"));
938        }
939
940        let storage = PswapNoteStorage::try_from(note.recipient().storage().items())?;
941
942        if note.assets().num_assets() != 1 {
943            return Err(NoteError::other("PSWAP note must have exactly one asset"));
944        }
945        let offered_asset = note
946            .assets()
947            .iter()
948            .next()
949            .expect("number of assets should have been validated")
950            .as_fungible()
951            .ok_or_else(|| NoteError::other("PSWAP note asset must be fungible"))?;
952
953        let attachment = match note.attachments().num_attachments() {
954            0 => None,
955            1 => {
956                Some(note.attachments().get(0).expect("length should have been validated").clone())
957            },
958            _ => return Err(NoteError::other("pswap note supports only one attachment")),
959        };
960
961        PswapNote::builder()
962            .sender(note.metadata().sender())
963            .storage(storage)
964            .serial_number(note.recipient().serial_num())
965            .note_type(note.metadata().note_type())
966            .offered_asset(offered_asset)
967            .maybe_attachment(attachment)
968            .build()
969    }
970}
971
972// NOTE CONSUMPTION COST
973// ================================================================================================
974
975impl NoteConsumptionCost for PswapNote {
976    fn consumption_cycles() -> u32 {
977        PSWAP_CONSUMPTION_CYCLES
978    }
979
980    /// Filling a PSWAP note creates the P2ID payback note for the swap creator and, on a
981    /// partial fill, the residual PSWAP note carrying the unfilled remainder.
982    fn created_notes() -> Vec<NoteScriptRoot> {
983        vec![P2idNote::script_root(), PswapNote::script_root()]
984    }
985}
986
987// TESTS
988// ================================================================================================
989
990#[cfg(test)]
991mod tests {
992    use miden_protocol::account::{AccountId, AccountIdVersion, AccountType, AssetCallbackFlag};
993    use miden_protocol::asset::FungibleAsset;
994    use miden_protocol::crypto::rand::{FeltRng, RandomCoin};
995    use rstest::rstest;
996
997    use super::*;
998
999    // TEST HELPERS
1000    // --------------------------------------------------------------------------------------------
1001
1002    fn dummy_faucet_id(byte: u8) -> AccountId {
1003        AccountId::builder()
1004            .account_type(AccountType::Public)
1005            .build_with_seed([byte; 32])
1006    }
1007
1008    fn dummy_creator_id() -> AccountId {
1009        AccountId::builder().account_type(AccountType::Public).build_with_seed([1; 32])
1010    }
1011
1012    fn dummy_consumer_id() -> AccountId {
1013        AccountId::builder().account_type(AccountType::Public).build_with_seed([2; 32])
1014    }
1015
1016    fn build_pswap_note(
1017        offered_asset: FungibleAsset,
1018        min_requested_asset: FungibleAsset,
1019        creator_id: AccountId,
1020    ) -> (PswapNote, Note) {
1021        let mut rng = RandomCoin::new(Word::default());
1022        let storage = PswapNoteStorage::builder()
1023            .min_requested_asset(min_requested_asset)
1024            .creator_account_id(creator_id)
1025            .build();
1026        let pswap = PswapNote::builder()
1027            .sender(creator_id)
1028            .storage(storage)
1029            .serial_number(rng.draw_word())
1030            .note_type(NoteType::Public)
1031            .offered_asset(offered_asset)
1032            .build()
1033            .unwrap();
1034        let note: Note = pswap.clone().into();
1035        (pswap, note)
1036    }
1037
1038    // TESTS
1039    // --------------------------------------------------------------------------------------------
1040
1041    #[test]
1042    fn pswap_note_creation_and_script() {
1043        let creator_id = dummy_creator_id();
1044        let offered_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 1000).unwrap();
1045        let min_requested_asset = FungibleAsset::new(dummy_faucet_id(0xbb), 500).unwrap();
1046
1047        let (pswap, note) = build_pswap_note(offered_asset, min_requested_asset, creator_id);
1048
1049        assert_eq!(pswap.sender(), creator_id);
1050        assert_eq!(pswap.note_type(), NoteType::Public);
1051
1052        let script = PswapNote::script();
1053        assert!(Word::from(script.root()) != Word::default(), "Script root should not be zero");
1054        assert_eq!(note.metadata().sender(), creator_id);
1055        assert_eq!(note.metadata().note_type(), NoteType::Public);
1056        assert_eq!(note.assets().num_assets(), 1);
1057        assert_eq!(note.recipient().script().root(), script.root());
1058        assert_eq!(
1059            note.recipient().storage().num_items(),
1060            PswapNoteStorage::NUM_STORAGE_ITEMS as u16,
1061        );
1062    }
1063
1064    #[test]
1065    fn pswap_note_builder() {
1066        let creator_id = dummy_creator_id();
1067        let offered_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 1000).unwrap();
1068        let min_requested_asset = FungibleAsset::new(dummy_faucet_id(0xbb), 500).unwrap();
1069
1070        let (pswap, note) = build_pswap_note(offered_asset, min_requested_asset, creator_id);
1071
1072        assert_eq!(pswap.sender(), creator_id);
1073        assert_eq!(pswap.note_type(), NoteType::Public);
1074        assert_eq!(note.metadata().sender(), creator_id);
1075        assert_eq!(note.metadata().note_type(), NoteType::Public);
1076        assert_eq!(note.assets().num_assets(), 1);
1077        assert_eq!(
1078            note.recipient().storage().num_items(),
1079            PswapNoteStorage::NUM_STORAGE_ITEMS as u16,
1080        );
1081    }
1082
1083    #[test]
1084    fn pswap_tag() {
1085        let mut offered_faucet_bytes = [0; 15];
1086        offered_faucet_bytes[0] = 0xcd;
1087        offered_faucet_bytes[1] = 0xb1;
1088
1089        let mut requested_faucet_bytes = [0; 15];
1090        requested_faucet_bytes[0] = 0xab;
1091        requested_faucet_bytes[1] = 0xec;
1092
1093        let offered_asset = FungibleAsset::new(
1094            AccountId::dummy(
1095                offered_faucet_bytes,
1096                AccountIdVersion::Version1,
1097                AccountType::Public,
1098                AssetCallbackFlag::Disabled,
1099            ),
1100            100,
1101        )
1102        .unwrap();
1103        let min_requested_asset = FungibleAsset::new(
1104            AccountId::dummy(
1105                requested_faucet_bytes,
1106                AccountIdVersion::Version1,
1107                AccountType::Public,
1108                AssetCallbackFlag::Disabled,
1109            ),
1110            200,
1111        )
1112        .unwrap();
1113
1114        let tag = PswapNote::create_tag(NoteType::Public, &offered_asset, &min_requested_asset);
1115        let tag_u32 = u32::from(tag);
1116
1117        // Verify note_type bits (top 2 bits should be 10 for Public)
1118        let note_type_bits = tag_u32 >> 30;
1119        assert_eq!(note_type_bits, NoteType::Public as u32);
1120    }
1121
1122    #[test]
1123    fn calculate_output_amount() {
1124        assert_eq!(PswapNote::calculate_output_amount(100, 100, 50).unwrap(), 50); // Equal ratio
1125        assert_eq!(PswapNote::calculate_output_amount(200, 100, 50).unwrap(), 100); // 2:1 ratio
1126        assert_eq!(PswapNote::calculate_output_amount(100, 200, 50).unwrap(), 25); // 1:2 ratio
1127
1128        // Non-integer ratio (100/73)
1129        let result = PswapNote::calculate_output_amount(100, 73, 7).unwrap();
1130        assert!(result > 0, "Should produce non-zero output");
1131    }
1132
1133    #[test]
1134    fn pswap_note_storage_try_from() {
1135        let creator_id = dummy_creator_id();
1136        let min_requested_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 500).unwrap();
1137
1138        // 7-element layout: [suffix, prefix, amount, min_fill_step, note_type, creator_suffix,
1139        // creator_prefix]. Creator is stored suffix-first to match the requested-faucet convention.
1140        let storage_items = vec![
1141            min_requested_asset.faucet_id().suffix(),
1142            min_requested_asset.faucet_id().prefix().as_felt(),
1143            Felt::from(min_requested_asset.amount()),
1144            Felt::try_from(100u64).unwrap(),       // min_fill_step
1145            Felt::from(NoteType::Private.as_u8()), // payback_note_type
1146            creator_id.suffix(),
1147            creator_id.prefix().as_felt(),
1148        ];
1149
1150        let parsed = PswapNoteStorage::try_from(storage_items.as_slice()).unwrap();
1151        assert_eq!(parsed.creator_account_id(), creator_id);
1152        assert_eq!(parsed.min_requested_amount(), 500);
1153        assert_eq!(parsed.min_fill_step().as_u64(), 100);
1154    }
1155
1156    #[test]
1157    fn pswap_note_storage_roundtrip() {
1158        let creator_id = dummy_creator_id();
1159        let min_requested_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 500).unwrap();
1160
1161        let storage = PswapNoteStorage::builder()
1162            .min_requested_asset(min_requested_asset)
1163            .creator_account_id(creator_id)
1164            .min_fill_step(AssetAmount::new(42).unwrap())
1165            .build();
1166
1167        let note_storage = NoteStorage::from(storage.clone());
1168        assert_eq!(note_storage.num_items(), PswapNoteStorage::NUM_STORAGE_ITEMS as u16);
1169
1170        let parsed = PswapNoteStorage::try_from(note_storage.items()).unwrap();
1171
1172        assert_eq!(parsed.creator_account_id(), creator_id);
1173        assert_eq!(parsed.min_requested_amount(), 500);
1174        assert_eq!(parsed.min_fill_step().as_u64(), 42);
1175    }
1176
1177    #[test]
1178    fn pswap_note_storage_defaults_min_fill_step_to_zero() {
1179        let creator_id = dummy_creator_id();
1180        let min_requested_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 500).unwrap();
1181
1182        let storage = PswapNoteStorage::builder()
1183            .min_requested_asset(min_requested_asset)
1184            .creator_account_id(creator_id)
1185            .build();
1186
1187        assert_eq!(
1188            storage.min_fill_step(),
1189            AssetAmount::ZERO,
1190            "min_fill_step must default to zero (no floor)",
1191        );
1192    }
1193
1194    /// `execute` mirrors the MASM floor: it rejects `total_fill = account_fill + note_fill` below
1195    /// `min(min_fill_step, min_requested_amount)` and accepts anything at or above it, with any
1196    /// remainder inheriting the floor. Cases are `(min_requested, min_fill_step, account_fill,
1197    /// note_fill, expect_ok)`; offered is 200 throughout.
1198    #[rstest]
1199    // Binding floor (min_fill_step <= min_requested): below / equal / above.
1200    #[case::below_floor(100, 30, 29, 0, false)]
1201    #[case::equal_floor(100, 30, 30, 0, true)]
1202    #[case::above_floor(100, 30, 50, 0, true)]
1203    // Clamp (min_requested < min_fill_step): a full fill at min_requested is accepted, below it
1204    // isn't.
1205    #[case::clamped_full_fill(20, 50, 20, 0, true)]
1206    #[case::clamped_below_both(20, 50, 10, 0, false)]
1207    // total_fill = account_fill + note_fill, checked as a sum: neither leg alone reaches the floor.
1208    #[case::two_legs_meet_floor(100, 30, 20, 20, true)]
1209    #[case::two_legs_below_floor(100, 30, 10, 10, false)]
1210    fn pswap_execute_enforces_min_fill_step(
1211        #[case] min_requested: u64,
1212        #[case] min_fill_step: u64,
1213        #[case] account_fill: u64,
1214        #[case] note_fill: u64,
1215        #[case] expect_ok: bool,
1216    ) {
1217        let creator_id = dummy_creator_id();
1218        let consumer_id = dummy_consumer_id();
1219        let offered_faucet = dummy_faucet_id(0xaa);
1220        let requested_faucet = dummy_faucet_id(0xbb);
1221
1222        let offered_asset = FungibleAsset::new(offered_faucet, 200).unwrap();
1223        let min_requested_asset = FungibleAsset::new(requested_faucet, min_requested).unwrap();
1224        let storage = PswapNoteStorage::builder()
1225            .min_requested_asset(min_requested_asset)
1226            .creator_account_id(creator_id)
1227            .min_fill_step(AssetAmount::new(min_fill_step).unwrap())
1228            .build();
1229        let mut rng = RandomCoin::new(Word::default());
1230        let pswap = PswapNote::builder()
1231            .sender(creator_id)
1232            .storage(storage)
1233            .serial_number(rng.draw_word())
1234            .note_type(NoteType::Public)
1235            .offered_asset(offered_asset)
1236            .build()
1237            .unwrap();
1238
1239        let leg = |amt: u64| (amt > 0).then(|| FungibleAsset::new(requested_faucet, amt).unwrap());
1240        let result = pswap.execute(consumer_id, leg(account_fill), leg(note_fill));
1241
1242        assert_eq!(result.is_ok(), expect_ok, "unexpected accept/reject for this fill");
1243
1244        if let Ok((_, remainder)) = result {
1245            // A partial fill (total below the requested minimum) leaves a remainder that must carry
1246            // the same floor; a full or over fill leaves none.
1247            if account_fill + note_fill < min_requested {
1248                let rem = remainder.expect("partial fill should produce a remainder");
1249                assert_eq!(
1250                    rem.storage().min_fill_step().as_u64(),
1251                    min_fill_step,
1252                    "remainder must inherit min_fill_step",
1253                );
1254            } else {
1255                assert!(remainder.is_none(), "full fill must complete the swap with no remainder");
1256            }
1257        }
1258    }
1259
1260    /// Consumer supplies both an account fill and a note fill, and the sum is below
1261    /// the requested amount → `execute` must combine them into a single payback note
1262    /// carrying account_fill+note_fill of the requested asset and emit a remainder
1263    /// pswap note for the unfilled portion.
1264    #[test]
1265    fn pswap_execute_combined_account_fill_and_note_fill_partial_fill() {
1266        let creator_id = dummy_creator_id();
1267        let consumer_id = dummy_consumer_id();
1268        let offered_faucet = dummy_faucet_id(0xaa);
1269        let requested_faucet = dummy_faucet_id(0xbb);
1270
1271        // Offer 100 offered, request 50 requested → 2:1 ratio.
1272        let offered_asset = FungibleAsset::new(offered_faucet, 100).unwrap();
1273        let min_requested_asset = FungibleAsset::new(requested_faucet, 50).unwrap();
1274        let (pswap, _) = build_pswap_note(offered_asset, min_requested_asset, creator_id);
1275
1276        // Account fill = 10, note fill = 20 → total fill = 30 (< 50, so partial).
1277        let account_fill = FungibleAsset::new(requested_faucet, 10).unwrap();
1278        let note_fill = FungibleAsset::new(requested_faucet, 20).unwrap();
1279
1280        let (payback, remainder) =
1281            pswap.execute(consumer_id, Some(account_fill), Some(note_fill)).unwrap();
1282
1283        // Payback note must carry the combined 30 of requested asset.
1284        assert_eq!(payback.assets().num_assets(), 1);
1285        let payback_asset = payback.assets().iter().next().unwrap();
1286        let fa = payback_asset.unwrap_fungible();
1287        assert_eq!(fa.faucet_id(), requested_faucet);
1288        assert_eq!(fa.amount().as_u64(), 30);
1289
1290        // Remainder must exist with the unfilled 50 - 30 = 20 of requested, and the
1291        // offered amount reduced proportionally (100 - 30*2 = 40).
1292        let remainder = remainder.expect("partial fill should produce remainder");
1293        assert_eq!(remainder.storage().min_requested_amount(), 20);
1294        assert_eq!(remainder.offered_asset().amount().as_u64(), 40);
1295        assert_eq!(remainder.storage().creator_account_id(), creator_id);
1296    }
1297
1298    /// Consumer supplies both an account fill and a note fill, and the sum exactly
1299    /// matches the requested amount → `execute` must produce a single payback note for
1300    /// the full amount and no remainder.
1301    #[test]
1302    fn pswap_execute_combined_account_fill_and_note_fill_full_fill() {
1303        let creator_id = dummy_creator_id();
1304        let consumer_id = dummy_consumer_id();
1305        let offered_faucet = dummy_faucet_id(0xaa);
1306        let requested_faucet = dummy_faucet_id(0xbb);
1307
1308        let offered_asset = FungibleAsset::new(offered_faucet, 100).unwrap();
1309        let min_requested_asset = FungibleAsset::new(requested_faucet, 50).unwrap();
1310        let (pswap, _) = build_pswap_note(offered_asset, min_requested_asset, creator_id);
1311
1312        // Account fill = 30, note fill = 20 → total fill = 50 (exactly requested).
1313        let account_fill = FungibleAsset::new(requested_faucet, 30).unwrap();
1314        let note_fill = FungibleAsset::new(requested_faucet, 20).unwrap();
1315
1316        let (payback, remainder) =
1317            pswap.execute(consumer_id, Some(account_fill), Some(note_fill)).unwrap();
1318
1319        // Payback note must carry the full 50 of requested asset.
1320        assert_eq!(payback.assets().num_assets(), 1);
1321        let payback_asset = payback.assets().iter().next().unwrap();
1322        let fa = payback_asset.unwrap_fungible();
1323        assert_eq!(fa.faucet_id(), requested_faucet);
1324        assert_eq!(fa.amount().as_u64(), 50);
1325
1326        // Full fill → no remainder note.
1327        assert!(remainder.is_none(), "full fill must not produce a remainder");
1328    }
1329
1330    /// A depth outside the u32 range the on-chain script enforces must be rejected when the
1331    /// note is built, and therefore also when a protocol note is decoded back into a
1332    /// [`PswapNote`].
1333    #[rstest]
1334    #[case::above_u32(Felt::new_unchecked(u64::from(u32::MAX) + 1))]
1335    #[case::wraps_the_field(Felt::MAX)]
1336    fn pswap_rejects_out_of_range_attachment_depth(#[case] depth: Felt) {
1337        let creator_id = dummy_creator_id();
1338        let offered_asset = FungibleAsset::new(dummy_faucet_id(0xaa), 100).unwrap();
1339        let min_requested_asset = FungibleAsset::new(dummy_faucet_id(0xbb), 50).unwrap();
1340
1341        let storage = PswapNoteStorage::builder()
1342            .min_requested_asset(min_requested_asset)
1343            .creator_account_id(creator_id)
1344            .build();
1345        let attachment = NoteAttachment::with_word(
1346            PswapNote::PSWAP_ATTACHMENT_SCHEME,
1347            Word::from([ONE, ONE, depth, ZERO]),
1348        );
1349
1350        let result = PswapNote::builder()
1351            .sender(creator_id)
1352            .storage(storage)
1353            .serial_number(RandomCoin::new(Word::default()).draw_word())
1354            .note_type(NoteType::Public)
1355            .offered_asset(offered_asset)
1356            .attachment(attachment)
1357            .build();
1358
1359        assert!(result.is_err(), "an out-of-range depth must not build a PswapNote");
1360    }
1361
1362    /// The lineage helpers offset the serial number by the distance between the note they are
1363    /// called on and the attachment's round, so a note that itself sits at a non-zero depth
1364    /// reconstructs the same round as the original does.
1365    #[test]
1366    fn pswap_lineage_helpers_are_relative_to_the_parent_depth() {
1367        let creator_id = dummy_creator_id();
1368        let consumer_id = dummy_consumer_id();
1369        let offered_faucet = dummy_faucet_id(0xaa);
1370        let requested_faucet = dummy_faucet_id(0xbb);
1371
1372        let offered_asset = FungibleAsset::new(offered_faucet, 100).unwrap();
1373        let min_requested_asset = FungibleAsset::new(requested_faucet, 50).unwrap();
1374        let (original, _) = build_pswap_note(offered_asset, min_requested_asset, creator_id);
1375
1376        // Round 1 leaves a remainder sitting at depth 1, which round 2 then consumes.
1377        let fill = FungibleAsset::new(requested_faucet, 20).unwrap();
1378        let (_, remainder) = original.execute(consumer_id, Some(fill), None).unwrap();
1379        let remainder = remainder.expect("partial fill should produce a remainder");
1380        assert_eq!(remainder.parent_depth(), 1);
1381
1382        let (round_two_payback, _) = remainder.execute(consumer_id, Some(fill), None).unwrap();
1383        let round_one_attachment = PswapNoteAttachment::try_from(
1384            remainder.attachments().expect("remainder carries an attachment"),
1385        )
1386        .unwrap();
1387        let round_two_attachment = PswapNoteAttachment::new(
1388            AssetAmount::new(20).unwrap(),
1389            round_one_attachment.order_id(),
1390            2,
1391        );
1392
1393        assert_eq!(
1394            original.payback_note(consumer_id, &round_two_attachment).unwrap().id(),
1395            round_two_payback.id(),
1396            "the original must reconstruct round 2 from its absolute depth",
1397        );
1398        assert_eq!(
1399            remainder.payback_note(consumer_id, &round_two_attachment).unwrap().id(),
1400            round_two_payback.id(),
1401            "the round's own parent must reconstruct it as well",
1402        );
1403        assert!(
1404            remainder.payback_note(consumer_id, &round_one_attachment).is_err(),
1405            "an attachment from the parent's own round is not a later round",
1406        );
1407    }
1408}