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