diff --git a/CHANGELOG.md b/CHANGELOG.md index a84aa03c2d..168e6e1576 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## v0.15.0 (TBD) +### Features + +- Added PSWAP (partial swap) note for decentralized partial-fill asset exchange with remainder note re-creation ([#2636](https://github.com/0xMiden/protocol/pull/2636)). ### Changes - [BREAKING] Renamed `ProvenBatch::new` to `new_unchecked` ([#2687](https://github.com/0xMiden/miden-base/issues/2687)). diff --git a/Cargo.lock b/Cargo.lock index 10b8e860d3..bafdee7cf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -350,6 +350,31 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bon" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -701,6 +726,40 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + [[package]] name = "debugid" version = "0.8.0" @@ -1227,6 +1286,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indenter" version = "0.3.4" @@ -1877,6 +1942,7 @@ version = "0.15.0" dependencies = [ "anyhow", "assert_matches", + "bon", "fs-err", "miden-assembly", "miden-core", @@ -3330,6 +3396,12 @@ dependencies = [ "vte", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index 932913e36b..a601f5238b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ miden-verifier = { default-features = false, version = "0.22" } alloy-sol-types = { default-features = false, version = "1.5" } anyhow = { default-features = false, features = ["backtrace", "std"], version = "1.0" } assert_matches = { default-features = false, version = "1.5" } +bon = { default-features = false, version = "3" } fs-err = { default-features = false, version = "3" } primitive-types = { default-features = false, version = "0.14" } rand = { default-features = false, version = "0.9" } diff --git a/crates/miden-standards/Cargo.toml b/crates/miden-standards/Cargo.toml index d4876b5cd9..2a9c42d7e0 100644 --- a/crates/miden-standards/Cargo.toml +++ b/crates/miden-standards/Cargo.toml @@ -25,6 +25,7 @@ miden-processor = { workspace = true } miden-protocol = { workspace = true } # External dependencies +bon = { workspace = true } rand = { optional = true, workspace = true } thiserror = { workspace = true } diff --git a/crates/miden-standards/asm/standards/notes/pswap.masm b/crates/miden-standards/asm/standards/notes/pswap.masm new file mode 100644 index 0000000000..6cde177530 --- /dev/null +++ b/crates/miden-standards/asm/standards/notes/pswap.masm @@ -0,0 +1,683 @@ +use miden::protocol::active_note +use miden::protocol::output_note +use miden::protocol::note +use miden::standards::wallets::basic->wallet +use miden::core::sys +use miden::protocol::active_account +use miden::core::math::u64 +use miden::protocol::asset +use miden::standards::notes::p2id + +# CONSTANTS +# ================================================================================================= + +const NOTE_TYPE_MASK=0x03 +const FACTOR=0x000186A0 # 1e5 +const MAX_U32=0x0000000100000000 + +# Memory Addresses +# ================================================================================================= + +# Memory Address Layout: +# - PSWAP Note Storage: addresses 0x0000 - 0x0011 (loaded from note storage) +# - Price Calculation: addresses 0x0028 - 0x0036 +# - Asset Keys: addresses 0x0038 - 0x003B, 0x003C - 0x003F (word-aligned) +# - Full Word (word-aligned): addresses 0x0018 - 0x001F + +# PSWAP Note Storage (18 items loaded at address 0) +# REQUESTED_ASSET_WORD_ITEM is the base address of an 8-cell block: +# addresses 0x0000-0x0003 = ASSET_KEY, 0x0004-0x0007 = ASSET_VALUE +const REQUESTED_ASSET_WORD_ITEM = 0x0000 +const REQUESTED_ASSET_VALUE_ITEM = 0x0004 +const PSWAP_TAG_ITEM = 0x0008 +const P2ID_TAG_ITEM = 0x0009 +const PSWAP_COUNT_ITEM = 0x000C +const PSWAP_CREATOR_PREFIX_ITEM = 0x0010 +const PSWAP_CREATOR_SUFFIX_ITEM = 0x0011 + +# Memory Addresses for Price Calculation Procedure +const AMT_OFFERED = 0x0028 +const AMT_REQUESTED = 0x0029 +const AMT_REQUESTED_IN = 0x002A +const AMT_OFFERED_OUT = 0x002B +const CALC_AMT_IN = 0x0031 + +# Inflight and split calculation addresses +const AMT_REQUESTED_INFLIGHT = 0x0033 +const AMT_OFFERED_OUT_INPUT = 0x0034 +const AMT_OFFERED_OUT_INFLIGHT = 0x0036 + +# Asset Key Memory Addresses (word-aligned, 4 cells each) +# ASSET_KEY = [asset_id_suffix, asset_id_prefix, faucet_id_suffix_and_metadata, faucet_id_prefix] +const OFFERED_ASSET_KEY = 0x0038 +const REQUESTED_ASSET_KEY = 0x003C + +# Full Word Memory Addresses +# Asset storage (8 cells each, word-aligned) +const OFFERED_ASSET_WORD = 0x0018 + +# Note indices and type +const P2ID_NOTE_IDX = 0x007C +const PSWAP_NOTE_IDX = 0x0080 +const NOTE_TYPE = 0x0084 + +# P2ID recipient storage (creator account ID written here for build_recipient) +# Layout matches P2ID note storage: [suffix, prefix] +const P2ID_RECIPIENT_SUFFIX = 0x0FA0 +const P2ID_RECIPIENT_PREFIX = 0x0FA1 + +# ERRORS +# ================================================================================================= + +# PSWAP script expects exactly 18 note storage items +const ERR_PSWAP_WRONG_NUMBER_OF_INPUTS="PSWAP script expects exactly 18 note storage items" + +# PSWAP script requires exactly one note asset +const ERR_PSWAP_WRONG_NUMBER_OF_ASSETS="PSWAP script requires exactly one note asset" + +# PSWAP fill amount (input + inflight) exceeds the total requested amount +const ERR_PSWAP_FILL_EXCEEDS_REQUESTED="PSWAP fill amount exceeds requested amount" + +# PRICE CALCULATION +# ================================================================================================= + +#! Computes the proportional amount of offered tokens for a given requested input. +#! +#! Uses u64 integer arithmetic with a precision factor of 1e5 to handle +#! non-integer ratios without floating point. +#! +#! Formula: +#! if input == requested: result = offered (full fill, avoids precision loss) +#! if offered >= requested: result = (offered * FACTOR / requested) * input / FACTOR +#! if requested > offered: result = (input * FACTOR) / (requested * FACTOR / offered) +#! +#! Inputs: [offered, requested, input] (offered on top) +#! Outputs: [offered_out] +#! +proc calculate_tokens_offered_for_requested + # u64 convention: lo is on TOP after u32split + # u32split(a) => [lo (top), hi] + # u64::wrapping_mul: [b_lo, b_hi, a_lo, a_hi] => [c_lo, c_hi] c = a*b + # u64::div: [b_lo, b_hi, a_lo, a_hi] => [q_lo, q_hi] q = a/b + # combine [lo, hi] => single Felt: swap push.MAX_U32 mul add + + movup.2 mem_store.CALC_AMT_IN + # => [offered, requested] + + # Early return: if input == requested (full fill), return offered directly. + # This avoids precision loss from integer division with the FACTOR. + dup.1 mem_load.CALC_AMT_IN eq + # => [requested == input, offered, requested] + + if.true + # Full fill: consumer provides all requested, gets all offered + swap drop + # => [offered] + else + + dup.1 dup.1 + # => [offered, requested, offered, requested] + + gt + # gt pops [b=offered, a=requested], pushes (a > b) i.e. (requested > offered) + # => [requested > offered, offered, requested] + + if.true + # Case: requested > offered + # ratio = (requested * FACTOR) / offered + # result = (input * FACTOR) / ratio + + swap + # => [requested, offered] + + u32split push.FACTOR u32split + # => [F_lo, F_hi, req_lo, req_hi, offered] + + exec.u64::wrapping_mul + # => [(req*F)_lo, (req*F)_hi, offered] + + movup.2 u32split + # => [off_lo, off_hi, (req*F)_lo, (req*F)_hi] + + exec.u64::div + # => [ratio_lo, ratio_hi] + + mem_load.CALC_AMT_IN u32split push.FACTOR u32split + # => [F_lo, F_hi, in_lo, in_hi, ratio_lo, ratio_hi] + + exec.u64::wrapping_mul + # => [(in*F)_lo, (in*F)_hi, ratio_lo, ratio_hi] + + movup.3 movup.3 + # => [ratio_lo, ratio_hi, (in*F)_lo, (in*F)_hi] + + exec.u64::div + # => [result_lo, result_hi] + + swap push.MAX_U32 mul add + # => [result] + + else + # Case: offered >= requested + # result = ((offered * FACTOR) / requested) * input / FACTOR + + u32split push.FACTOR u32split + # => [F_lo, F_hi, off_lo, off_hi, requested] + + exec.u64::wrapping_mul + # => [(off*F)_lo, (off*F)_hi, requested] + + movup.2 u32split + # => [req_lo, req_hi, (off*F)_lo, (off*F)_hi] + + exec.u64::div + # => [ratio_lo, ratio_hi] + + mem_load.CALC_AMT_IN u32split + # => [in_lo, in_hi, ratio_lo, ratio_hi] + + exec.u64::wrapping_mul + # => [(rat*in)_lo, (rat*in)_hi] + + push.FACTOR u32split + # => [F_lo, F_hi, (rat*in)_lo, (rat*in)_hi] + + exec.u64::div + # => [result_lo, result_hi] + + swap push.MAX_U32 mul add + # => [result] + + end + + end +end + +# METADATA PROCEDURES +# ================================================================================================= + +#! Extracts the note_type from the active note's metadata and stores it at NOTE_TYPE. +#! +#! get_metadata returns [NOTE_ATTACHMENT(4), METADATA_HEADER(4)]. +#! METADATA_HEADER word layout (see NoteMetadataHeader in miden-protocol/src/note/metadata.rs): +#! word[0] = sender_suffix_and_note_type (note_type in bits 0-1) +#! word[1] = sender_id_prefix +#! word[2] = tag +#! word[3] = attachment_kind_scheme +#! +#! After dropw and mem_loadw_le ordering, word[0] is on top of the stack. +#! +#! Inputs: [] +#! Outputs: [] +#! +proc extract_note_type + exec.active_note::get_metadata + # => [NOTE_ATTACHMENT(4), METADATA_HEADER(4)] + dropw + # => [word[0]=sender_suffix_and_note_type, word[1]=prefix, word[2]=tag, word[3]=attachment] + # Keep word[0] (top), move it to bottom and drop the rest + movdn.3 drop drop drop + # => [sender_suffix_and_note_type] + u32split + # => [lo32, hi32] (note_type in bits 0-1 of lo32, lo32 on top) + push.NOTE_TYPE_MASK u32and + # => [note_type, hi32] + mem_store.NOTE_TYPE + drop + # => [] +end + +# HASHING PROCEDURES +# ================================================================================================= + +#! Builds the P2ID recipient hash for the swap creator. +#! +#! Loads the creator's account ID from note storage (PSWAP_CREATOR_SUFFIX/PREFIX_ITEM), +#! stores it as P2ID note storage [suffix, prefix] at a temp address, and calls +#! note::build_recipient to compute the recipient commitment. +#! +#! Inputs: [SERIAL_NUM, SCRIPT_ROOT] +#! Outputs: [P2ID_RECIPIENT] +#! +proc build_p2id_recipient_hash + # Store creator [suffix, prefix] for P2ID recipient hashing + mem_load.PSWAP_CREATOR_SUFFIX_ITEM mem_store.P2ID_RECIPIENT_SUFFIX + mem_load.PSWAP_CREATOR_PREFIX_ITEM mem_store.P2ID_RECIPIENT_PREFIX + # => [SERIAL_NUM, SCRIPT_ROOT] + + # note::build_recipient: [storage_ptr, num_storage_items, SERIAL_NUM, SCRIPT_ROOT] => [RECIPIENT] + push.2.P2ID_RECIPIENT_SUFFIX + # => [storage_ptr, num_storage_items=2, SERIAL_NUM, SCRIPT_ROOT] + + exec.note::build_recipient + # => [P2ID_RECIPIENT] +end + +# P2ID NOTE CREATION PROCEDURE +# ================================================================================================= + +#! Creates a P2ID output note for the swap creator. +#! +#! Derives a unique serial number from the swap count and the active note's serial, +#! computes the P2ID recipient, creates the output note, sets the attachment, +#! and adds the requested assets (from vault and/or inflight). +#! +#! Inputs: [] +#! Outputs: [] +#! +proc create_p2id_note + # Get P2ID script root at compile time via procref + procref.p2id::main + # => [P2ID_SCRIPT_ROOT] + + # Increment swap count (ensures unique serial per P2ID note in chained fills) + mem_load.PSWAP_COUNT_ITEM add.1 mem_store.PSWAP_COUNT_ITEM + # => [P2ID_SCRIPT_ROOT] + + # Load swap count word from memory + padw mem_loadw_le.PSWAP_COUNT_ITEM + # => [SWAP_COUNT_WORD, P2ID_SCRIPT_ROOT] + + # Get serial number from active note + exec.active_note::get_serial_number + # => [SERIAL_NUM, SWAP_COUNT_WORD, P2ID_SCRIPT_ROOT] + + # Derive P2ID serial: hmerge(SWAP_COUNT_WORD, SERIAL_NUM) + swapw + # => [SWAP_COUNT_WORD, SERIAL_NUM, P2ID_SCRIPT_ROOT] + hmerge + # => [P2ID_SERIAL_NUM, P2ID_SCRIPT_ROOT] + + # Build P2ID recipient + exec.build_p2id_recipient_hash + # => [P2ID_RECIPIENT] + + # Create output note (note_type inherited from active note metadata) + mem_load.NOTE_TYPE + # => [note_type, P2ID_RECIPIENT] + + mem_load.P2ID_TAG_ITEM + # => [tag, note_type, RECIPIENT] + + exec.output_note::create + # => [note_idx] + + mem_store.P2ID_NOTE_IDX + # => [] + + # Set attachment: aux = input_amount + inflight_amount (total fill) + # attachment_scheme = 0 (NoteAttachmentScheme::none) + # See: output_note::set_word_attachment in miden-protocol/asm/protocol/output_note.masm + mem_load.AMT_REQUESTED_IN mem_load.AMT_REQUESTED_INFLIGHT add + push.0.0.0 + # => [0, 0, 0, aux] + + push.0 mem_load.P2ID_NOTE_IDX + # => [note_idx, attachment_scheme=0, ATTACHMENT] + + exec.output_note::set_word_attachment + # => [] + + # Move input_amount from consumer's vault to P2ID note (if > 0) + mem_load.AMT_REQUESTED_IN dup push.0 neq + # => [amt != 0, amt] + if.true + drop + + # Build 16-element call frame: [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] + padw push.0.0.0 + # => [pad(7)] + + mem_load.P2ID_NOTE_IDX + # => [note_idx, pad(7)] + + # Build ASSET_VALUE = [amount, 0, 0, 0] (amount on top) + push.0.0.0 mem_load.AMT_REQUESTED_IN + # => [amount, 0, 0, 0, note_idx, pad(7)] + + # Load stored ASSET_KEY + padw mem_loadw_le.REQUESTED_ASSET_KEY + # => [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] + + call.wallet::move_asset_to_note + # => [pad(16)] + + dropw dropw dropw dropw + # => [] + else + drop + end + + # Add inflight_amount directly to P2ID note (no vault debit, if > 0) + mem_load.AMT_REQUESTED_INFLIGHT dup push.0 neq + # => [amt != 0, amt] + if.true + drop + + mem_load.P2ID_NOTE_IDX + # => [note_idx] + + # Build ASSET_VALUE = [amount, 0, 0, 0] (amount on top) + push.0.0.0 mem_load.AMT_REQUESTED_INFLIGHT + # => [amount, 0, 0, 0, note_idx] + + # Load stored ASSET_KEY + padw mem_loadw_le.REQUESTED_ASSET_KEY + # => [ASSET_KEY, ASSET_VALUE, note_idx] + + exec.output_note::add_asset + # => [] + else + drop + end +end + +# REMAINDER NOTE CREATION PROCEDURE +# ================================================================================================= + +#! Creates a PSWAP remainder output note for partial fills. +#! +#! Updates the requested amount in note storage, builds a new remainder recipient +#! (using the active note's script root and a serial derived by incrementing the +#! top element of the active note's serial number), creates the output note, +#! sets the attachment, and adds the remaining offered asset. +#! +#! Inputs: [remaining_requested] +#! Outputs: [] +#! +proc create_remainder_note + # Update note storage with new requested amount + mem_store.REQUESTED_ASSET_VALUE_ITEM + # => [] + + # Build PSWAP remainder recipient using the same script as the active note + exec.active_note::get_script_root + # => [SCRIPT_ROOT] + + # Derive remainder serial: increment top element of active note's serial number + exec.active_note::get_serial_number add.1 + # => [SERIAL_NUM', SCRIPT_ROOT] + + # Build recipient from all 18 note storage items (now with updated requested amount) + push.18.0 + # => [storage_ptr=0, num_storage_items=18, SERIAL_NUM', SCRIPT_ROOT] + + exec.note::build_recipient + # => [RECIPIENT_SWAPP] + + mem_load.NOTE_TYPE + mem_load.PSWAP_TAG_ITEM + + exec.output_note::create + # => [note_idx] + + mem_store.PSWAP_NOTE_IDX + # => [] + + # Set attachment: aux = total offered_out amount + mem_load.AMT_OFFERED_OUT push.0.0.0 + # => [0, 0, 0, aux] + + push.0 mem_load.PSWAP_NOTE_IDX + # => [note_idx, attachment_scheme, ATTACHMENT] + + exec.output_note::set_word_attachment + # => [] + + # Add remaining offered asset to remainder note + # remainder_amount = total_offered - offered_out + mem_load.PSWAP_NOTE_IDX + # => [note_idx] + + # Build ASSET_VALUE = [amount, 0, 0, 0] (amount on top) + push.0.0.0 + mem_load.AMT_OFFERED mem_load.AMT_OFFERED_OUT sub + # => [remainder_amount, 0, 0, 0, note_idx] + + # Load stored ASSET_KEY + padw mem_loadw_le.OFFERED_ASSET_KEY + # => [ASSET_KEY, ASSET_VALUE, note_idx] + + exec.output_note::add_asset + # => [] +end + +#! Checks if the currently consuming account is the creator of the note. +#! +#! Compares the active account's ID against the creator ID stored in note storage. +#! Note storage must already be loaded to memory by the caller. +#! +#! Inputs: [] +#! Outputs: [is_creator] +#! +proc is_consumer_creator + exec.active_account::get_id + # => [acct_id_suffix, acct_id_prefix] + + mem_load.PSWAP_CREATOR_SUFFIX_ITEM mem_load.PSWAP_CREATOR_PREFIX_ITEM + # => [creator_prefix, creator_suffix, acct_id_suffix, acct_id_prefix] + + movup.3 eq + # => [prefix_eq, creator_suffix, acct_id_suffix] + + movdn.2 + # => [creator_suffix, acct_id_suffix, prefix_eq] + + eq + # => [suffix_eq, prefix_eq] + + and + # => [is_creator] +end + +#! Reclaims all assets from the note back to the creator's vault. +#! +#! Called when the consumer IS the creator (cancel/reclaim path). +#! +#! Inputs: [] +#! Outputs: [] +#! +proc handle_reclaim + push.OFFERED_ASSET_WORD exec.active_note::get_assets + # => [num_assets, dest_ptr] + drop drop + # => [] + + # Load asset from memory (KEY+VALUE format, 8 cells) + push.OFFERED_ASSET_WORD exec.asset::load + # => [ASSET_KEY, ASSET_VALUE] + + # Build 16-element call frame: [ASSET_KEY, ASSET_VALUE, pad(8)] + padw padw swapdw + # => [ASSET_KEY, ASSET_VALUE, pad(8)] + + call.wallet::receive_asset + # => [pad(16)] + + dropw dropw dropw dropw + # => [] +end + +# PSWAP EXECUTION +# ================================================================================================= +# +# Executes the partially-fillable swap. Sends offered tokens to consumer, requested tokens +# to creator via P2ID, and creates a remainder note if partially filled. +# +# Note args (Word[0] on top after mem_loadw_le): +# Word[0] = input_amount: debited from consumer's vault +# Word[1] = inflight_amount: added directly to P2ID note (no vault debit) +# Word[2..3] = unused (0) +# + +proc execute_pswap + # Load note assets to OFFERED_ASSET_WORD + push.OFFERED_ASSET_WORD exec.active_note::get_assets + # => [num_assets, asset_ptr] + + push.1 eq assert.err=ERR_PSWAP_WRONG_NUMBER_OF_ASSETS + # => [asset_ptr] + + drop + # => [] + + # Load offered asset from known address + push.OFFERED_ASSET_WORD exec.asset::load + # => [ASSET_KEY, ASSET_VALUE] + + # Extract offered amount + exec.asset::fungible_to_amount + # => [amount, ASSET_KEY, ASSET_VALUE] + + mem_store.AMT_OFFERED + # => [ASSET_KEY, ASSET_VALUE] + + # Store offered ASSET_KEY (preserves faucet ID + callback metadata) + mem_storew_le.OFFERED_ASSET_KEY dropw + # => [ASSET_VALUE] + dropw + # => [] + + # Load requested asset from note storage (ASSET_KEY + ASSET_VALUE, 8 cells) + push.REQUESTED_ASSET_WORD_ITEM exec.asset::load + # => [ASSET_KEY, ASSET_VALUE] + + # Extract requested amount + exec.asset::fungible_to_amount + # => [amount, ASSET_KEY, ASSET_VALUE] + + mem_store.AMT_REQUESTED + # => [ASSET_KEY, ASSET_VALUE] + + # Store requested ASSET_KEY (preserves faucet ID + callback metadata) + mem_storew_le.REQUESTED_ASSET_KEY dropw + # => [ASSET_VALUE] + dropw + # => [] + + # If both input and inflight are 0, default to a full fill (input = requested). + # This enables consumption by network accounts, which execute without note_args + # (the kernel defaults note_args to [0, 0, 0, 0] when none are provided). + mem_load.AMT_REQUESTED_IN mem_load.AMT_REQUESTED_INFLIGHT add push.0 eq + if.true + mem_load.AMT_REQUESTED mem_store.AMT_REQUESTED_IN + end + + # Validate: fill amount (input + inflight) must not exceed total requested + mem_load.AMT_REQUESTED_IN mem_load.AMT_REQUESTED_INFLIGHT add + mem_load.AMT_REQUESTED + # => [requested, fill_amount] + # lte pops [b=requested, a=fill_amount] and pushes (fill_amount <= requested) + lte assert.err=ERR_PSWAP_FILL_EXCEEDS_REQUESTED + # => [] + + # Calculate offered_out for input and inflight amounts separately rather than + # summing them first, because the input portion (offered_out_input) must be sent + # to the consumer's vault individually, while the total (input + inflight) is + # needed to determine the remainder note's offered amount. + # + # Calculate offered_out for input_amount + mem_load.AMT_REQUESTED_IN + mem_load.AMT_REQUESTED + mem_load.AMT_OFFERED + # => [offered, requested, input_amount] + exec.calculate_tokens_offered_for_requested + # => [input_offered_out] + + mem_store.AMT_OFFERED_OUT_INPUT + # => [] + + # Calculate offered_out for inflight_amount + mem_load.AMT_REQUESTED_INFLIGHT + mem_load.AMT_REQUESTED + mem_load.AMT_OFFERED + # => [offered, requested, inflight_amount] + + exec.calculate_tokens_offered_for_requested + # => [inflight_offered_out] + mem_store.AMT_OFFERED_OUT_INFLIGHT + # => [] + + # total_offered_out = input_offered_out + inflight_offered_out + mem_load.AMT_OFFERED_OUT_INPUT mem_load.AMT_OFFERED_OUT_INFLIGHT add + # => [total_offered_out] + + mem_store.AMT_OFFERED_OUT + # => [] + + # Create P2ID note for creator + exec.create_p2id_note + # => [] + + # Consumer receives only input_offered_out into vault (not inflight portion) + padw padw + push.OFFERED_ASSET_WORD exec.asset::load + # => [ASSET_KEY, ASSET_VALUE, pad(8)] + # Replace amount (ASSET_VALUE[0]) with input_offered_out + movup.4 + drop + mem_load.AMT_OFFERED_OUT_INPUT + movdn.4 + # => [ASSET_KEY, ASSET_VALUE', pad(8)] + call.wallet::receive_asset + dropw dropw dropw dropw + # => [] + + # Check if partial fill: total_in < total_requested + mem_load.AMT_REQUESTED_IN mem_load.AMT_REQUESTED_INFLIGHT add + mem_load.AMT_REQUESTED + dup.1 dup.1 lt + + if.true + # remaining_requested = total_requested - total_in + swap sub + # => [remaining_requested] + + exec.create_remainder_note + else + drop drop + end + + exec.sys::truncate_stack +end + +@note_script +pub proc main + # => [NOTE_ARGS] + # Stack (top to bottom): [input_amount, inflight_amount, 0, 0] + # (Word[0] on top after mem_loadw_le in kernel prologue) + # + # In network transactions, note_args are not provided by the executor and default + # to [0, 0, 0, 0]. The script handles this by defaulting to a full fill. + + mem_store.AMT_REQUESTED_IN + # => [inflight_amount, 0, 0] + + mem_store.AMT_REQUESTED_INFLIGHT + # => [0, 0] + drop drop + # => [] + + # Load all 18 note storage items to memory starting at address 0 + push.0 exec.active_note::get_storage + # => [num_storage_items, storage_ptr] + + eq.18 assert.err=ERR_PSWAP_WRONG_NUMBER_OF_INPUTS + # => [storage_ptr] + + drop + # => [] + + # Extract and store note_type from active note metadata + exec.extract_note_type + # => [] + + exec.is_consumer_creator + # => [is_creator] + + if.true + exec.handle_reclaim + else + exec.execute_pswap + end +end diff --git a/crates/miden-standards/src/note/mod.rs b/crates/miden-standards/src/note/mod.rs index 7da32ea234..eaef854e68 100644 --- a/crates/miden-standards/src/note/mod.rs +++ b/crates/miden-standards/src/note/mod.rs @@ -26,6 +26,9 @@ pub use p2id::{P2idNote, P2idNoteStorage}; mod p2ide; pub use p2ide::{P2ideNote, P2ideNoteStorage}; +mod pswap; +pub use pswap::{PswapNote, PswapNoteStorage}; + mod swap; pub use swap::{SwapNote, SwapNoteStorage}; @@ -46,6 +49,7 @@ pub enum StandardNote { P2ID, P2IDE, SWAP, + PSWAP, MINT, BURN, } @@ -72,6 +76,9 @@ impl StandardNote { if root == SwapNote::script_root() { return Some(Self::SWAP); } + if root == PswapNote::script_root() { + return Some(Self::PSWAP); + } if root == MintNote::script_root() { return Some(Self::MINT); } @@ -91,6 +98,7 @@ impl StandardNote { Self::P2ID => "P2ID", Self::P2IDE => "P2IDE", Self::SWAP => "SWAP", + Self::PSWAP => "PSWAP", Self::MINT => "MINT", Self::BURN => "BURN", } @@ -102,6 +110,7 @@ impl StandardNote { Self::P2ID => P2idNote::NUM_STORAGE_ITEMS, Self::P2IDE => P2ideNote::NUM_STORAGE_ITEMS, Self::SWAP => SwapNote::NUM_STORAGE_ITEMS, + Self::PSWAP => PswapNoteStorage::NUM_STORAGE_ITEMS, Self::MINT => MintNote::NUM_STORAGE_ITEMS_PRIVATE, Self::BURN => BurnNote::NUM_STORAGE_ITEMS, } @@ -113,6 +122,7 @@ impl StandardNote { Self::P2ID => P2idNote::script(), Self::P2IDE => P2ideNote::script(), Self::SWAP => SwapNote::script(), + Self::PSWAP => PswapNote::script(), Self::MINT => MintNote::script(), Self::BURN => BurnNote::script(), } @@ -124,6 +134,7 @@ impl StandardNote { Self::P2ID => P2idNote::script_root(), Self::P2IDE => P2ideNote::script_root(), Self::SWAP => SwapNote::script_root(), + Self::PSWAP => PswapNote::script_root(), Self::MINT => MintNote::script_root(), Self::BURN => BurnNote::script_root(), } @@ -143,9 +154,9 @@ impl StandardNote { // the provided account interface. interface_proc_digests.contains(&BasicWallet::receive_asset_digest()) }, - Self::SWAP => { - // To consume SWAP note, the `receive_asset` and `move_asset_to_note` procedures - // must be present in the provided account interface. + Self::SWAP | Self::PSWAP => { + // To consume SWAP/PSWAP notes, the `receive_asset` and `move_asset_to_note` + // procedures must be present in the provided account interface. interface_proc_digests.contains(&BasicWallet::receive_asset_digest()) && interface_proc_digests.contains(&BasicWallet::move_asset_to_note_digest()) }, diff --git a/crates/miden-standards/src/note/pswap.rs b/crates/miden-standards/src/note/pswap.rs new file mode 100644 index 0000000000..2db75ae2ba --- /dev/null +++ b/crates/miden-standards/src/note/pswap.rs @@ -0,0 +1,930 @@ +use alloc::vec; + +use miden_protocol::account::AccountId; +use miden_protocol::assembly::Path; +use miden_protocol::asset::{Asset, FungibleAsset}; +use miden_protocol::errors::NoteError; +use miden_protocol::note::{ + Note, NoteAssets, NoteAttachment, NoteAttachmentScheme, NoteMetadata, NoteRecipient, + NoteScript, NoteStorage, NoteTag, NoteType, +}; +use miden_protocol::utils::sync::LazyLock; +use miden_protocol::{Felt, Hasher, ONE, Word, ZERO}; + +use crate::StandardsLib; +use crate::note::P2idNoteStorage; + +// NOTE SCRIPT +// ================================================================================================ + +/// Path to the PSWAP note script procedure in the standards library. +const PSWAP_SCRIPT_PATH: &str = "::miden::standards::notes::pswap::main"; + +// Initialize the PSWAP note script only once +static PSWAP_SCRIPT: LazyLock = LazyLock::new(|| { + let standards_lib = StandardsLib::default(); + let path = Path::new(PSWAP_SCRIPT_PATH); + NoteScript::from_library_reference(standards_lib.as_ref(), path) + .expect("Standards library contains PSWAP note script procedure") +}); + +// PSWAP NOTE STORAGE +// ================================================================================================ + +/// Canonical storage representation for a PSWAP note. +/// +/// Maps to the 18-element [`NoteStorage`] layout consumed by the on-chain MASM script: +/// +/// | Slot | Field | +/// |---------|-------| +/// | `[0-3]` | Requested asset key (`asset.to_key_word()`) | +/// | `[4-7]` | Requested asset value (`asset.to_value_word()`) | +/// | `[8]` | PSWAP note tag | +/// | `[9]` | Payback note routing tag (targets the creator) | +/// | `[10-11]` | Reserved (zero) | +/// | `[12]` | Swap count (incremented on each partial fill) | +/// | `[13-15]` | Reserved (zero) | +/// | `[16-17]` | Creator account ID (prefix, suffix) | +#[derive(Debug, Clone, PartialEq, Eq, bon::Builder)] +pub struct PswapNoteStorage { + requested_asset: FungibleAsset, + + #[builder(default)] + pswap_tag: NoteTag, + + #[builder(default)] + swap_count: u16, + + creator_account_id: AccountId, +} + +impl PswapNoteStorage { + // CONSTANTS + // -------------------------------------------------------------------------------------------- + + /// Expected number of storage items for the PSWAP note. + pub const NUM_STORAGE_ITEMS: usize = 18; + + /// Consumes the storage and returns a PSWAP [`NoteRecipient`] with the provided serial number. + pub fn into_recipient(self, serial_num: Word) -> NoteRecipient { + NoteRecipient::new(serial_num, PswapNote::script(), NoteStorage::from(self)) + } + + /// Overwrites the PSWAP tag. Called during [`Note`] conversion once the tag can be derived + /// from the offered/requested asset pair. + pub(crate) fn with_pswap_tag(mut self, tag: NoteTag) -> Self { + self.pswap_tag = tag; + self + } + + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns a reference to the requested [`FungibleAsset`]. + pub fn requested_asset(&self) -> &FungibleAsset { + &self.requested_asset + } + + /// Returns the PSWAP note tag. This may be the default (zero) tag until the note + /// is converted into a [`Note`], at which point the tag is derived from the + /// offered/requested asset pair. + pub fn pswap_tag(&self) -> NoteTag { + self.pswap_tag + } + + /// Returns the payback note routing tag, derived from the creator's account ID. + pub fn payback_note_tag(&self) -> NoteTag { + NoteTag::with_account_target(self.creator_account_id) + } + + /// Number of times this note has been partially filled and re-created. + pub fn swap_count(&self) -> u16 { + self.swap_count + } + + /// Returns the account ID of the note creator. + pub fn creator_account_id(&self) -> AccountId { + self.creator_account_id + } + + /// Returns the faucet ID of the requested asset. + pub fn requested_faucet_id(&self) -> AccountId { + self.requested_asset.faucet_id() + } + + /// Returns the requested token amount. + pub fn requested_asset_amount(&self) -> u64 { + self.requested_asset.amount() + } +} + +/// Serializes [`PswapNoteStorage`] into an 18-element [`NoteStorage`]. +impl From for NoteStorage { + fn from(storage: PswapNoteStorage) -> Self { + let asset = Asset::Fungible(storage.requested_asset); + let key_word = asset.to_key_word(); + let value_word = asset.to_value_word(); + + let storage_items = vec![ + // ASSET_KEY [0-3] + key_word[0], + key_word[1], + key_word[2], + key_word[3], + // ASSET_VALUE [4-7] + value_word[0], + value_word[1], + value_word[2], + value_word[3], + // Tags [8-9] + Felt::from(storage.pswap_tag), + Felt::from(storage.payback_note_tag()), + // Padding [10-11] + ZERO, + ZERO, + // Swap count [12-15] + Felt::from(storage.swap_count), + ZERO, + ZERO, + ZERO, + // Creator ID [16-17] + storage.creator_account_id.prefix().as_felt(), + storage.creator_account_id.suffix(), + ]; + NoteStorage::new(storage_items) + .expect("number of storage items should not exceed max storage items") + } +} + +/// Deserializes [`PswapNoteStorage`] from a slice of exactly 18 [`Felt`]s. +impl TryFrom<&[Felt]> for PswapNoteStorage { + type Error = NoteError; + + fn try_from(note_storage: &[Felt]) -> Result { + if note_storage.len() != Self::NUM_STORAGE_ITEMS { + return Err(NoteError::InvalidNoteStorageLength { + expected: Self::NUM_STORAGE_ITEMS, + actual: note_storage.len(), + }); + } + + let requested_asset_key = + Word::from([note_storage[0], note_storage[1], note_storage[2], note_storage[3]]); + let requested_asset_value = + Word::from([note_storage[4], note_storage[5], note_storage[6], note_storage[7]]); + let requested_asset = + FungibleAsset::from_key_value_words(requested_asset_key, requested_asset_value) + .map_err(|e| { + NoteError::other_with_source("failed to parse requested asset from storage", e) + })?; + + let pswap_tag = NoteTag::new( + u32::try_from(note_storage[8].as_canonical_u64()) + .map_err(|_| NoteError::other("pswap_tag exceeds u32"))?, + ); + let swap_count: u16 = note_storage[12] + .as_canonical_u64() + .try_into() + .map_err(|_| NoteError::other("swap_count exceeds u16"))?; + + let creator_account_id = AccountId::try_from_elements(note_storage[17], note_storage[16]) + .map_err(|e| { + NoteError::other_with_source("failed to parse creator account ID", e) + })?; + + Ok(Self { + requested_asset, + pswap_tag, + swap_count, + creator_account_id, + }) + } +} + +// PSWAP NOTE +// ================================================================================================ + +/// A partially-fillable swap note for decentralized asset exchange. +/// +/// A PSWAP note allows a creator to offer one fungible asset in exchange for another. +/// Unlike a regular SWAP note, consumers may fill it partially — the unfilled portion +/// is re-created as a remainder note with an incremented swap count, while the creator +/// receives the filled portion via a payback note. +/// +/// The note can be consumed both in local transactions (where the consumer provides +/// fill amounts via note_args) and in network transactions (where note_args default to +/// `[0, 0, 0, 0]`, triggering a full fill). To route a PSWAP note to a network account, +/// set the `attachment` to a [`NetworkAccountTarget`](crate::note::NetworkAccountTarget) +/// via the builder. +#[derive(Debug, Clone, bon::Builder)] +#[builder(finish_fn(vis = "", name = build_internal))] +pub struct PswapNote { + sender: AccountId, + storage: PswapNoteStorage, + serial_number: Word, + + #[builder(default = NoteType::Private)] + note_type: NoteType, + + offered_asset: FungibleAsset, + + #[builder(default)] + attachment: NoteAttachment, +} + +impl PswapNoteBuilder +where + S: pswap_note_builder::IsComplete, +{ + /// Validates and builds the [`PswapNote`]. + /// + /// # Errors + /// + /// Returns an error if the offered and requested assets have the same faucet ID. + pub fn build(self) -> Result { + let note = self.build_internal(); + + if note.offered_asset.faucet_id() == note.storage.requested_faucet_id() { + return Err(NoteError::other( + "offered and requested assets must have different faucets", + )); + } + + Ok(note) + } +} + +impl PswapNote { + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the compiled PSWAP note script. + pub fn script() -> NoteScript { + PSWAP_SCRIPT.clone() + } + + /// Returns the root hash of the PSWAP note script. + pub fn script_root() -> Word { + PSWAP_SCRIPT.root() + } + + /// Returns the account ID of the note sender. + pub fn sender(&self) -> AccountId { + self.sender + } + + /// Returns a reference to the PSWAP note storage. + pub fn storage(&self) -> &PswapNoteStorage { + &self.storage + } + + /// Returns the serial number of this note. + pub fn serial_number(&self) -> Word { + self.serial_number + } + + /// Returns the note type (public or private). + pub fn note_type(&self) -> NoteType { + self.note_type + } + + /// Returns a reference to the offered [`FungibleAsset`]. + pub fn offered_asset(&self) -> &FungibleAsset { + &self.offered_asset + } + + /// Returns a reference to the note attachment. + /// + /// For notes targeting a network account, this may contain a + /// [`NetworkAccountTarget`](crate::note::NetworkAccountTarget) with scheme = 1. + /// For local-only notes, this is typically `NoteAttachmentScheme::none()`. + pub fn attachment(&self) -> &NoteAttachment { + &self.attachment + } + + // INSTANCE METHODS + // -------------------------------------------------------------------------------------------- + + /// Executes the swap as a full fill, intended for network transactions. + /// + /// In network transactions, note_args are unavailable (the kernel defaults them to + /// `[0, 0, 0, 0]`), so the MASM script fills the entire requested amount. This method + /// mirrors that behavior. Returns only the payback note — no remainder is produced. + /// + /// # Errors + /// + /// Returns an error if the swap count overflows `u16::MAX`. + pub fn execute_full_fill_network( + &self, + network_account_id: AccountId, + ) -> Result { + let requested_faucet_id = self.storage.requested_faucet_id(); + let total_requested_amount = self.storage.requested_asset_amount(); + + let fill_asset = FungibleAsset::new(requested_faucet_id, total_requested_amount) + .map_err(|e| NoteError::other_with_source("failed to create full fill asset", e))?; + + self.create_payback_note(network_account_id, fill_asset, total_requested_amount) + } + + /// Executes the swap, producing the output notes for a given fill. + /// + /// `input_asset` is debited from the consumer's vault; `inflight_asset` arrives + /// from another note in the same transaction (cross-swap). At least one must be + /// provided. + /// + /// Returns `(payback_note, Option)`. The remainder is + /// `None` when the fill equals the total requested amount (full fill). + /// + /// # Errors + /// + /// Returns an error if: + /// - Both assets are `None`. + /// - The fill amount is zero. + /// - The fill amount exceeds the total requested amount. + /// - The swap count overflows `u16::MAX`. + pub fn execute( + &self, + consumer_account_id: AccountId, + input_asset: Option, + inflight_asset: Option, + ) -> Result<(Note, Option), NoteError> { + if input_asset.is_none() && inflight_asset.is_none() { + return Err(NoteError::other( + "at least one of input_asset or inflight_asset must be provided", + )); + } + + // Combine input and inflight into a single payback asset + let input_amount = input_asset.as_ref().map_or(0, |a| a.amount()); + let inflight_amount = inflight_asset.as_ref().map_or(0, |a| a.amount()); + let payback_asset = match (input_asset, inflight_asset) { + (Some(input), Some(inflight)) => input.add(inflight).map_err(|e| { + NoteError::other_with_source("failed to combine input and inflight assets", e) + })?, + (Some(asset), None) | (None, Some(asset)) => asset, + (None, None) => unreachable!("validated above"), + }; + let fill_amount = payback_asset.amount(); + + let total_offered_amount = self.offered_asset.amount(); + let requested_faucet_id = self.storage.requested_faucet_id(); + let total_requested_amount = self.storage.requested_asset_amount(); + + // Validate fill amount + if fill_amount == 0 { + return Err(NoteError::other("Fill amount must be greater than 0")); + } + if fill_amount > total_requested_amount { + return Err(NoteError::other(alloc::format!( + "Fill amount {} exceeds requested amount {}", + fill_amount, + total_requested_amount + ))); + } + + // Calculate offered amounts separately for input and inflight, matching the MASM + // which calls calculate_tokens_offered_for_requested twice. This is necessary + // because the input portion goes to the consumer's vault while the total determines + // the remainder note's offered amount. + let offered_for_input = Self::calculate_output_amount( + total_offered_amount, + total_requested_amount, + input_amount, + ); + let offered_for_inflight = Self::calculate_output_amount( + total_offered_amount, + total_requested_amount, + inflight_amount, + ); + let offered_amount_for_fill = offered_for_input + offered_for_inflight; + + let payback_note = + self.create_payback_note(consumer_account_id, payback_asset, fill_amount)?; + + // Create remainder note if partial fill + let remainder = if fill_amount < total_requested_amount { + let remaining_offered = total_offered_amount - offered_amount_for_fill; + let remaining_requested = total_requested_amount - fill_amount; + + let remaining_offered_asset = + FungibleAsset::new(self.offered_asset.faucet_id(), remaining_offered).map_err( + |e| NoteError::other_with_source("failed to create remainder asset", e), + )?; + + let remaining_requested_asset = + FungibleAsset::new(requested_faucet_id, remaining_requested).map_err(|e| { + NoteError::other_with_source("failed to create remaining requested asset", e) + })?; + + Some(self.create_remainder_pswap_note( + consumer_account_id, + remaining_offered_asset, + remaining_requested_asset, + offered_amount_for_fill, + )?) + } else { + None + }; + + Ok((payback_note, remainder)) + } + + /// Returns how many offered tokens a consumer receives for `input_amount` of the + /// requested asset, based on this note's current offered/requested ratio. + pub fn calculate_offered_for_requested(&self, input_amount: u64) -> u64 { + let total_requested = self.storage.requested_asset_amount(); + let total_offered = self.offered_asset.amount(); + + Self::calculate_output_amount(total_offered, total_requested, input_amount) + } + + // ASSOCIATED FUNCTIONS + // -------------------------------------------------------------------------------------------- + + /// Builds the 32-bit [`NoteTag`] for a PSWAP note. + /// + /// ```text + /// [31..30] note_type (2 bits) + /// [29..16] script_root MSBs (14 bits) + /// [15..8] offered faucet ID (8 bits, top byte of prefix) + /// [7..0] requested faucet ID (8 bits, top byte of prefix) + /// ``` + pub fn create_tag( + note_type: NoteType, + offered_asset: &FungibleAsset, + requested_asset: &FungibleAsset, + ) -> NoteTag { + let pswap_root_bytes = Self::script().root().as_bytes(); + + // Construct the pswap use case ID from the 14 most significant bits of the script root. + // This leaves the two most significant bits zero. + let mut pswap_use_case_id = (pswap_root_bytes[0] as u16) << 6; + pswap_use_case_id |= (pswap_root_bytes[1] >> 2) as u16; + + // Get bits 0..8 from the faucet IDs of both assets which will form the tag payload. + let offered_asset_id: u64 = offered_asset.faucet_id().prefix().into(); + let offered_asset_tag = (offered_asset_id >> 56) as u8; + + let requested_asset_id: u64 = requested_asset.faucet_id().prefix().into(); + let requested_asset_tag = (requested_asset_id >> 56) as u8; + + let asset_pair = ((offered_asset_tag as u16) << 8) | (requested_asset_tag as u16); + + let tag = ((note_type as u8 as u32) << 30) + | ((pswap_use_case_id as u32) << 16) + | asset_pair as u32; + + NoteTag::new(tag) + } + + /// Computes `offered_total * input_amount / requested_total` using fixed-point + /// u64 arithmetic with a precision factor of 10^5, matching the on-chain MASM + /// calculation. Returns the full `offered_total` when `input_amount == requested_total`. + /// + /// The formula is implemented in two branches to maximize precision: + /// - When `offered > requested`: the ratio `offered/requested` is >= 1, so we compute + /// `(offered * FACTOR / requested) * input / FACTOR` to avoid losing the fractional part. + /// - When `requested >= offered`: the ratio `offered/requested` is < 1, so computing it + /// directly would truncate to zero. Instead we compute the inverse ratio + /// `(requested * FACTOR / offered)` and divide: `(input * FACTOR) / inverse_ratio`. + fn calculate_output_amount(offered_total: u64, requested_total: u64, input_amount: u64) -> u64 { + const PRECISION_FACTOR: u64 = 100_000; + + if requested_total == input_amount { + return offered_total; + } + + if offered_total > requested_total { + let ratio = (offered_total * PRECISION_FACTOR) / requested_total; + (input_amount * ratio) / PRECISION_FACTOR + } else { + let ratio = (requested_total * PRECISION_FACTOR) / offered_total; + (input_amount * PRECISION_FACTOR) / ratio + } + } + + /// Builds a payback note (P2ID) that delivers the filled assets to the swap creator. + /// + /// The note inherits its type (public/private) from this PSWAP note and derives a + /// deterministic serial number via `hmerge(swap_count + 1, serial_num)`. + /// + /// The attachment carries the fill amount as auxiliary data with + /// `NoteAttachmentScheme::none()`, matching the on-chain MASM behavior. + fn create_payback_note( + &self, + consumer_account_id: AccountId, + payback_asset: FungibleAsset, + fill_amount: u64, + ) -> Result { + let payback_note_tag = self.storage.payback_note_tag(); + // Derive P2ID serial matching PSWAP.masm + let next_swap_count = self + .storage + .swap_count + .checked_add(1) + .ok_or_else(|| NoteError::other("swap count overflow"))?; + let swap_count_word = Word::from([Felt::from(next_swap_count), ZERO, ZERO, ZERO]); + let p2id_serial_num = Hasher::merge(&[swap_count_word, self.serial_number]); + + // P2ID recipient targets the creator + let recipient = + P2idNoteStorage::new(self.storage.creator_account_id).into_recipient(p2id_serial_num); + + let attachment_word = Word::from([ + Felt::try_from(fill_amount).expect("fill amount should fit in a felt"), + ZERO, + ZERO, + ZERO, + ]); + let attachment = NoteAttachment::new_word(NoteAttachmentScheme::none(), attachment_word); + + let p2id_assets = NoteAssets::new(vec![Asset::Fungible(payback_asset)])?; + let p2id_metadata = NoteMetadata::new(consumer_account_id, self.note_type) + .with_tag(payback_note_tag) + .with_attachment(attachment); + + Ok(Note::new(p2id_assets, p2id_metadata, recipient)) + } + + /// Builds a remainder PSWAP note carrying the unfilled portion of the swap. + /// + /// The remainder inherits the original creator, tags, and note type, but has an + /// incremented swap count and an updated serial number (`serial[0] + 1`). + /// + /// The attachment carries the total offered amount for the fill as auxiliary data + /// with `NoteAttachmentScheme::none()`, matching the on-chain MASM behavior. + fn create_remainder_pswap_note( + &self, + consumer_account_id: AccountId, + remaining_offered_asset: FungibleAsset, + remaining_requested_asset: FungibleAsset, + offered_amount_for_fill: u64, + ) -> Result { + let next_swap_count = self + .storage + .swap_count + .checked_add(1) + .ok_or_else(|| NoteError::other("swap count overflow"))?; + let new_storage = PswapNoteStorage::builder() + .requested_asset(remaining_requested_asset) + .pswap_tag(self.storage.pswap_tag) + .swap_count(next_swap_count) + .creator_account_id(self.storage.creator_account_id) + .build(); + + // Remainder serial: increment top element (matching MASM add.1 on Word[0]) + let remainder_serial_num = Word::from([ + self.serial_number[0] + ONE, + self.serial_number[1], + self.serial_number[2], + self.serial_number[3], + ]); + + let attachment_word = Word::from([ + Felt::try_from(offered_amount_for_fill) + .expect("offered amount for fill should fit in a felt"), + ZERO, + ZERO, + ZERO, + ]); + let attachment = NoteAttachment::new_word(NoteAttachmentScheme::none(), attachment_word); + + Ok(PswapNote { + sender: consumer_account_id, + storage: new_storage, + serial_number: remainder_serial_num, + note_type: self.note_type, + offered_asset: remaining_offered_asset, + attachment, + }) + } +} + +// CONVERSIONS +// ================================================================================================ + +/// Converts a [`PswapNote`] into a protocol [`Note`], computing the final PSWAP tag. +impl From for Note { + fn from(pswap: PswapNote) -> Self { + let tag = PswapNote::create_tag( + pswap.note_type, + &pswap.offered_asset, + pswap.storage.requested_asset(), + ); + + let storage = pswap.storage.with_pswap_tag(tag); + let recipient = storage.into_recipient(pswap.serial_number); + + let assets = NoteAssets::new(vec![Asset::Fungible(pswap.offered_asset)]) + .expect("single fungible asset should be valid"); + + let metadata = NoteMetadata::new(pswap.sender, pswap.note_type) + .with_tag(tag) + .with_attachment(pswap.attachment); + + Note::new(assets, metadata, recipient) + } +} + +/// Parses a protocol [`Note`] back into a [`PswapNote`] by deserializing its storage. +impl TryFrom<&Note> for PswapNote { + type Error = NoteError; + + fn try_from(note: &Note) -> Result { + if note.recipient().script().root() != PswapNote::script_root() { + return Err(NoteError::other("note script root does not match PSWAP script root")); + } + + let storage = PswapNoteStorage::try_from(note.recipient().storage().items())?; + + if note.assets().num_assets() != 1 { + return Err(NoteError::other("PSWAP note must have exactly one asset")); + } + let offered_asset = match note.assets().iter().next().unwrap() { + Asset::Fungible(fa) => *fa, + Asset::NonFungible(_) => { + return Err(NoteError::other("PSWAP note asset must be fungible")); + }, + }; + + Ok(Self { + sender: note.metadata().sender(), + storage, + serial_number: note.recipient().serial_num(), + note_type: note.metadata().note_type(), + offered_asset, + attachment: note.metadata().attachment().clone(), + }) + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use miden_protocol::account::{AccountId, AccountIdVersion, AccountStorageMode, AccountType}; + use miden_protocol::asset::FungibleAsset; + use miden_protocol::crypto::rand::{FeltRng, RandomCoin}; + + use super::*; + + #[test] + fn pswap_note_creation_and_script() { + let mut offered_faucet_bytes = [0; 15]; + offered_faucet_bytes[0] = 0xaa; + + let mut requested_faucet_bytes = [0; 15]; + requested_faucet_bytes[0] = 0xbb; + + let offered_faucet_id = AccountId::dummy( + offered_faucet_bytes, + AccountIdVersion::Version0, + AccountType::FungibleFaucet, + AccountStorageMode::Public, + ); + + let requested_faucet_id = AccountId::dummy( + requested_faucet_bytes, + AccountIdVersion::Version0, + AccountType::FungibleFaucet, + AccountStorageMode::Public, + ); + + let creator_id = AccountId::dummy( + [1; 15], + AccountIdVersion::Version0, + AccountType::RegularAccountImmutableCode, + AccountStorageMode::Public, + ); + + let offered_asset = FungibleAsset::new(offered_faucet_id, 1000).unwrap(); + let requested_asset = FungibleAsset::new(requested_faucet_id, 500).unwrap(); + + let mut rng = RandomCoin::new(Word::default()); + + let script = PswapNote::script(); + assert!(script.root() != Word::default(), "Script root should not be zero"); + + let storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(creator_id) + .build(); + let pswap = PswapNote::builder() + .sender(creator_id) + .storage(storage) + .serial_number(rng.draw_word()) + .note_type(NoteType::Public) + .offered_asset(offered_asset) + .build() + .unwrap(); + + let note: Note = pswap.into(); + + assert_eq!(note.metadata().sender(), creator_id); + assert_eq!(note.metadata().note_type(), NoteType::Public); + assert_eq!(note.assets().num_assets(), 1); + assert_eq!(note.recipient().script().root(), script.root()); + + // Verify storage has 18 items + assert_eq!( + note.recipient().storage().num_items(), + PswapNoteStorage::NUM_STORAGE_ITEMS as u16, + ); + } + + #[test] + fn pswap_note_builder() { + let mut offered_faucet_bytes = [0; 15]; + offered_faucet_bytes[0] = 0xaa; + + let mut requested_faucet_bytes = [0; 15]; + requested_faucet_bytes[0] = 0xbb; + + let offered_faucet_id = AccountId::dummy( + offered_faucet_bytes, + AccountIdVersion::Version0, + AccountType::FungibleFaucet, + AccountStorageMode::Public, + ); + + let requested_faucet_id = AccountId::dummy( + requested_faucet_bytes, + AccountIdVersion::Version0, + AccountType::FungibleFaucet, + AccountStorageMode::Public, + ); + + let creator_id = AccountId::dummy( + [1; 15], + AccountIdVersion::Version0, + AccountType::RegularAccountImmutableCode, + AccountStorageMode::Public, + ); + + let offered_asset = FungibleAsset::new(offered_faucet_id, 1000).unwrap(); + let requested_asset = FungibleAsset::new(requested_faucet_id, 500).unwrap(); + + let mut rng = RandomCoin::new(Word::default()); + + let storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(creator_id) + .build(); + let pswap = PswapNote::builder() + .sender(creator_id) + .storage(storage) + .serial_number(rng.draw_word()) + .note_type(NoteType::Public) + .offered_asset(offered_asset) + .build() + .unwrap(); + + assert_eq!(pswap.sender(), creator_id); + assert_eq!(pswap.note_type(), NoteType::Public); + + // Convert to Note + let note: Note = pswap.into(); + assert_eq!(note.metadata().sender(), creator_id); + assert_eq!(note.metadata().note_type(), NoteType::Public); + assert_eq!(note.assets().num_assets(), 1); + assert_eq!( + note.recipient().storage().num_items(), + PswapNoteStorage::NUM_STORAGE_ITEMS as u16, + ); + } + + #[test] + fn pswap_tag() { + let mut offered_faucet_bytes = [0; 15]; + offered_faucet_bytes[0] = 0xcd; + offered_faucet_bytes[1] = 0xb1; + + let mut requested_faucet_bytes = [0; 15]; + requested_faucet_bytes[0] = 0xab; + requested_faucet_bytes[1] = 0xec; + + let offered_asset = FungibleAsset::new( + AccountId::dummy( + offered_faucet_bytes, + AccountIdVersion::Version0, + AccountType::FungibleFaucet, + AccountStorageMode::Public, + ), + 100, + ) + .unwrap(); + let requested_asset = FungibleAsset::new( + AccountId::dummy( + requested_faucet_bytes, + AccountIdVersion::Version0, + AccountType::FungibleFaucet, + AccountStorageMode::Public, + ), + 200, + ) + .unwrap(); + + let tag = PswapNote::create_tag(NoteType::Public, &offered_asset, &requested_asset); + let tag_u32 = u32::from(tag); + + // Verify note_type bits (top 2 bits should be 10 for Public) + let note_type_bits = tag_u32 >> 30; + assert_eq!(note_type_bits, NoteType::Public as u32); + } + + #[test] + fn calculate_output_amount() { + // Equal ratio + assert_eq!(PswapNote::calculate_output_amount(100, 100, 50), 50); + + // 2:1 ratio + assert_eq!(PswapNote::calculate_output_amount(200, 100, 50), 100); + + // 1:2 ratio + assert_eq!(PswapNote::calculate_output_amount(100, 200, 50), 25); + + // Non-integer ratio (100/73) + let result = PswapNote::calculate_output_amount(100, 73, 7); + assert!(result > 0, "Should produce non-zero output"); + } + + #[test] + fn pswap_note_storage_try_from() { + let creator_id = AccountId::dummy( + [1; 15], + AccountIdVersion::Version0, + AccountType::RegularAccountImmutableCode, + AccountStorageMode::Public, + ); + + let faucet_id = AccountId::dummy( + [0xaa; 15], + AccountIdVersion::Version0, + AccountType::FungibleFaucet, + AccountStorageMode::Public, + ); + + let asset = Asset::Fungible(FungibleAsset::new(faucet_id, 500).unwrap()); + let key_word = asset.to_key_word(); + let value_word = asset.to_value_word(); + + let storage_items = vec![ + key_word[0], + key_word[1], + key_word[2], + key_word[3], + value_word[0], + value_word[1], + value_word[2], + value_word[3], + Felt::from(0xc0000000u32), // pswap_tag + Felt::from(0x80000001u32), // payback_note_tag + ZERO, + ZERO, + Felt::from(3u16), // swap_count + ZERO, + ZERO, + ZERO, + creator_id.prefix().as_felt(), + creator_id.suffix(), + ]; + + let parsed = PswapNoteStorage::try_from(storage_items.as_slice()).unwrap(); + assert_eq!(parsed.swap_count(), 3); + assert_eq!(parsed.creator_account_id(), creator_id); + assert_eq!(parsed.requested_asset_amount(), 500); + } + + #[test] + fn pswap_note_storage_roundtrip() { + let creator_id = AccountId::dummy( + [1; 15], + AccountIdVersion::Version0, + AccountType::RegularAccountImmutableCode, + AccountStorageMode::Public, + ); + + let faucet_id = AccountId::dummy( + [0xaa; 15], + AccountIdVersion::Version0, + AccountType::FungibleFaucet, + AccountStorageMode::Public, + ); + + let requested_asset = FungibleAsset::new(faucet_id, 500).unwrap(); + let storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(creator_id) + .build(); + + // Convert to NoteStorage and back + let note_storage = NoteStorage::from(storage.clone()); + let parsed = PswapNoteStorage::try_from(note_storage.items()).unwrap(); + + assert_eq!(parsed.creator_account_id(), creator_id); + assert_eq!(parsed.swap_count(), 0); + assert_eq!(parsed.requested_asset_amount(), 500); + } +} diff --git a/crates/miden-testing/tests/scripts/mod.rs b/crates/miden-testing/tests/scripts/mod.rs index 8d15402744..9b8c3e12e5 100644 --- a/crates/miden-testing/tests/scripts/mod.rs +++ b/crates/miden-testing/tests/scripts/mod.rs @@ -3,5 +3,6 @@ mod fee; mod ownable2step; mod p2id; mod p2ide; +mod pswap; mod send_note; mod swap; diff --git a/crates/miden-testing/tests/scripts/pswap.rs b/crates/miden-testing/tests/scripts/pswap.rs new file mode 100644 index 0000000000..2abee51df7 --- /dev/null +++ b/crates/miden-testing/tests/scripts/pswap.rs @@ -0,0 +1,1200 @@ +use std::collections::BTreeMap; + +use miden_protocol::account::auth::AuthScheme; +use miden_protocol::account::{Account, AccountStorageMode}; +use miden_protocol::asset::{Asset, FungibleAsset}; +use miden_protocol::crypto::rand::{FeltRng, RandomCoin}; +use miden_protocol::note::{Note, NoteAssets, NoteMetadata, NoteRecipient, NoteStorage, NoteType}; +use miden_protocol::transaction::RawOutputNote; +use miden_protocol::{Felt, Word, ONE, ZERO}; +use miden_standards::account::wallets::BasicWallet; +use miden_standards::note::{PswapNote, PswapNoteStorage}; +use miden_testing::{Auth, MockChain}; + +// CONSTANTS +// ================================================================================================ + +const BASIC_AUTH: Auth = Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, +}; + +// TESTS +// ================================================================================================ + +#[tokio::test] +async fn pswap_note_full_fill_test() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let usdc_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 1000, Some(150))?; + let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1000, Some(50))?; + + let alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], + )?; + let bob = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), 25)?.into()], + )?; + + let offered_asset = FungibleAsset::new(usdc_faucet.id(), 50)?; + let requested_asset = FungibleAsset::new(eth_faucet.id(), 25)?; + + let mut rng = RandomCoin::new(Word::default()); + let storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(alice.id()) + .build(); + let pswap_note: Note = PswapNote::builder() + .sender(alice.id()) + .storage(storage) + .serial_number(rng.draw_word()) + .note_type(NoteType::Public) + .offered_asset(offered_asset) + .build()? + .into(); + builder.add_output_note(RawOutputNote::Full(pswap_note.clone())); + + let mut mock_chain = builder.build()?; + + let mut note_args_map = BTreeMap::new(); + note_args_map.insert(pswap_note.id(), Word::from([Felt::from(25u32), Felt::from(0u32), ZERO, ZERO])); + + let pswap = PswapNote::try_from(&pswap_note)?; + let (p2id_note, _remainder) = + pswap.execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), 25)?), None)?; + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[pswap_note.id()], &[])? + .extend_note_args(note_args_map) + .extend_expected_output_notes(vec![RawOutputNote::Full(p2id_note.clone())]) + .build()?; + + let executed_transaction = tx_context.execute().await?; + + // Verify: 1 P2ID note with 25 ETH + let output_notes = executed_transaction.output_notes(); + assert_eq!(output_notes.num_notes(), 1, "Expected exactly 1 P2ID note"); + + let actual_recipient = output_notes.get_note(0).recipient_digest(); + let expected_recipient = p2id_note.recipient().digest(); + assert_eq!(actual_recipient, expected_recipient, "RECIPIENT MISMATCH!"); + + let p2id_assets = output_notes.get_note(0).assets(); + assert_eq!(p2id_assets.num_assets(), 1); + if let Asset::Fungible(f) = p2id_assets.iter().next().unwrap() { + assert_eq!(f.faucet_id(), eth_faucet.id()); + assert_eq!(f.amount(), 25); + } else { + panic!("Expected fungible asset in P2ID note"); + } + + // Verify Bob's vault delta: +50 USDC, -25 ETH + let vault_delta = executed_transaction.account_delta().vault(); + let added: Vec = vault_delta.added_assets().collect(); + let removed: Vec = vault_delta.removed_assets().collect(); + + assert_eq!(added.len(), 1); + assert_eq!(removed.len(), 1); + if let Asset::Fungible(f) = &added[0] { + assert_eq!(f.faucet_id(), usdc_faucet.id()); + assert_eq!(f.amount(), 50); + } + if let Asset::Fungible(f) = &removed[0] { + assert_eq!(f.faucet_id(), eth_faucet.id()); + assert_eq!(f.amount(), 25); + } + + mock_chain.add_pending_executed_transaction(&executed_transaction)?; + let _ = mock_chain.prove_next_block(); + + Ok(()) +} + +#[tokio::test] +async fn pswap_note_private_full_fill_test() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let usdc_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 1000, Some(150))?; + let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1000, Some(50))?; + + let alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], + )?; + let bob = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), 25)?.into()], + )?; + + let offered_asset = FungibleAsset::new(usdc_faucet.id(), 50)?; + let requested_asset = FungibleAsset::new(eth_faucet.id(), 25)?; + + let mut rng = RandomCoin::new(Word::default()); + // Create a PRIVATE swap note (output notes should also be Private) + let storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(alice.id()) + .build(); + let pswap_note: Note = PswapNote::builder() + .sender(alice.id()) + .storage(storage) + .serial_number(rng.draw_word()) + .note_type(NoteType::Private) + .offered_asset(offered_asset) + .build()? + .into(); + builder.add_output_note(RawOutputNote::Full(pswap_note.clone())); + + let mut mock_chain = builder.build()?; + + let mut note_args_map = BTreeMap::new(); + note_args_map.insert(pswap_note.id(), Word::from([Felt::from(25u32), Felt::from(0u32), ZERO, ZERO])); + + // Expected P2ID note should inherit Private type from swap note + let pswap = PswapNote::try_from(&pswap_note)?; + let (p2id_note, _remainder) = + pswap.execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), 25)?), None)?; + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[pswap_note.id()], &[])? + .extend_note_args(note_args_map) + .extend_expected_output_notes(vec![RawOutputNote::Full(p2id_note)]) + .build()?; + + let executed_transaction = tx_context.execute().await?; + + // Verify: 1 P2ID note with 25 ETH + let output_notes = executed_transaction.output_notes(); + assert_eq!(output_notes.num_notes(), 1, "Expected exactly 1 P2ID note"); + + let p2id_assets = output_notes.get_note(0).assets(); + assert_eq!(p2id_assets.num_assets(), 1); + if let Asset::Fungible(f) = p2id_assets.iter().next().unwrap() { + assert_eq!(f.faucet_id(), eth_faucet.id()); + assert_eq!(f.amount(), 25); + } else { + panic!("Expected fungible asset in P2ID note"); + } + + // Verify Bob's vault delta: +50 USDC, -25 ETH + let vault_delta = executed_transaction.account_delta().vault(); + let added: Vec = vault_delta.added_assets().collect(); + let removed: Vec = vault_delta.removed_assets().collect(); + + assert_eq!(added.len(), 1); + assert_eq!(removed.len(), 1); + if let Asset::Fungible(f) = &added[0] { + assert_eq!(f.faucet_id(), usdc_faucet.id()); + assert_eq!(f.amount(), 50); + } + if let Asset::Fungible(f) = &removed[0] { + assert_eq!(f.faucet_id(), eth_faucet.id()); + assert_eq!(f.amount(), 25); + } + + mock_chain.add_pending_executed_transaction(&executed_transaction)?; + let _ = mock_chain.prove_next_block(); + + Ok(()) +} + +#[tokio::test] +async fn pswap_note_partial_fill_test() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let usdc_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 1000, Some(150))?; + let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1000, Some(50))?; + + let alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], + )?; + let bob = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), 20)?.into()], + )?; + + let offered_asset = FungibleAsset::new(usdc_faucet.id(), 50)?; + let requested_asset = FungibleAsset::new(eth_faucet.id(), 25)?; + + let mut rng = RandomCoin::new(Word::default()); + let storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(alice.id()) + .build(); + let pswap_note: Note = PswapNote::builder() + .sender(alice.id()) + .storage(storage) + .serial_number(rng.draw_word()) + .note_type(NoteType::Public) + .offered_asset(offered_asset) + .build()? + .into(); + builder.add_output_note(RawOutputNote::Full(pswap_note.clone())); + + let mut mock_chain = builder.build()?; + + let mut note_args_map = BTreeMap::new(); + note_args_map.insert(pswap_note.id(), Word::from([Felt::from(20u32), Felt::from(0u32), ZERO, ZERO])); + + let pswap = PswapNote::try_from(&pswap_note)?; + let (p2id_note, remainder_pswap) = + pswap.execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), 20)?), None)?; + let remainder_note = + Note::from(remainder_pswap.expect("partial fill should produce remainder")); + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[pswap_note.id()], &[])? + .extend_note_args(note_args_map) + .extend_expected_output_notes(vec![ + RawOutputNote::Full(p2id_note), + RawOutputNote::Full(remainder_note), + ]) + .build()?; + + let executed_transaction = tx_context.execute().await?; + + // Verify: 2 output notes (P2ID + remainder) + let output_notes = executed_transaction.output_notes(); + assert_eq!(output_notes.num_notes(), 2); + + // P2ID note: 20 ETH + if let Asset::Fungible(f) = output_notes.get_note(0).assets().iter().next().unwrap() { + assert_eq!(f.faucet_id(), eth_faucet.id()); + assert_eq!(f.amount(), 20); + } + + // SWAPp remainder: 10 USDC + if let Asset::Fungible(f) = output_notes.get_note(1).assets().iter().next().unwrap() { + assert_eq!(f.faucet_id(), usdc_faucet.id()); + assert_eq!(f.amount(), 10); + } + + // Bob's vault: +40 USDC, -20 ETH + let vault_delta = executed_transaction.account_delta().vault(); + let added: Vec = vault_delta.added_assets().collect(); + let removed: Vec = vault_delta.removed_assets().collect(); + assert_eq!(added.len(), 1); + assert_eq!(removed.len(), 1); + if let Asset::Fungible(f) = &added[0] { + assert_eq!(f.faucet_id(), usdc_faucet.id()); + assert_eq!(f.amount(), 40); + } + if let Asset::Fungible(f) = &removed[0] { + assert_eq!(f.faucet_id(), eth_faucet.id()); + assert_eq!(f.amount(), 20); + } + + mock_chain.add_pending_executed_transaction(&executed_transaction)?; + let _ = mock_chain.prove_next_block(); + + Ok(()) +} + +#[tokio::test] +async fn pswap_note_inflight_cross_swap_test() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let usdc_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 1000, Some(150))?; + let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1000, Some(50))?; + + let alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], + )?; + let bob = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), 25)?.into()], + )?; + let charlie = builder.add_existing_wallet_with_assets(BASIC_AUTH, [])?; + + let mut rng = RandomCoin::new(Word::default()); + + // Alice's note: offers 50 USDC, requests 25 ETH + let alice_requested_asset = FungibleAsset::new(eth_faucet.id(), 25)?; + let storage = PswapNoteStorage::builder() + .requested_asset(alice_requested_asset) + .creator_account_id(alice.id()) + .build(); + let alice_pswap_note: Note = PswapNote::builder() + .sender(alice.id()) + .storage(storage) + .serial_number(rng.draw_word()) + .note_type(NoteType::Public) + .offered_asset(FungibleAsset::new(usdc_faucet.id(), 50)?) + .build()? + .into(); + builder.add_output_note(RawOutputNote::Full(alice_pswap_note.clone())); + + // Bob's note: offers 25 ETH, requests 50 USDC + let bob_requested_asset = FungibleAsset::new(usdc_faucet.id(), 50)?; + let storage = PswapNoteStorage::builder() + .requested_asset(bob_requested_asset) + .creator_account_id(bob.id()) + .build(); + let bob_pswap_note: Note = PswapNote::builder() + .sender(bob.id()) + .storage(storage) + .serial_number(rng.draw_word()) + .note_type(NoteType::Public) + .offered_asset(FungibleAsset::new(eth_faucet.id(), 25)?) + .build()? + .into(); + builder.add_output_note(RawOutputNote::Full(bob_pswap_note.clone())); + + let mock_chain = builder.build()?; + + // Note args: pure inflight (input=0, inflight=full amount) + let mut note_args_map = BTreeMap::new(); + note_args_map + .insert(alice_pswap_note.id(), Word::from([Felt::from(0u32), Felt::from(25u32), ZERO, ZERO])); + note_args_map + .insert(bob_pswap_note.id(), Word::from([Felt::from(0u32), Felt::from(50u32), ZERO, ZERO])); + + // Expected P2ID notes + let alice_pswap = PswapNote::try_from(&alice_pswap_note)?; + let (alice_p2id_note, _) = + alice_pswap.execute(charlie.id(), None, Some(FungibleAsset::new(eth_faucet.id(), 25)?))?; + + let bob_pswap = PswapNote::try_from(&bob_pswap_note)?; + let (bob_p2id_note, _) = + bob_pswap.execute(charlie.id(), None, Some(FungibleAsset::new(usdc_faucet.id(), 50)?))?; + + let tx_context = mock_chain + .build_tx_context(charlie.id(), &[alice_pswap_note.id(), bob_pswap_note.id()], &[])? + .extend_note_args(note_args_map) + .extend_expected_output_notes(vec![ + RawOutputNote::Full(alice_p2id_note), + RawOutputNote::Full(bob_p2id_note), + ]) + .build()?; + + let executed_transaction = tx_context.execute().await?; + + // Verify: 2 P2ID notes + let output_notes = executed_transaction.output_notes(); + assert_eq!(output_notes.num_notes(), 2); + + let mut alice_found = false; + let mut bob_found = false; + for idx in 0..output_notes.num_notes() { + if let Asset::Fungible(f) = output_notes.get_note(idx).assets().iter().next().unwrap() { + if f.faucet_id() == eth_faucet.id() && f.amount() == 25 { + alice_found = true; + } + if f.faucet_id() == usdc_faucet.id() && f.amount() == 50 { + bob_found = true; + } + } + } + assert!(alice_found, "Alice's P2ID note (25 ETH) not found"); + assert!(bob_found, "Bob's P2ID note (50 USDC) not found"); + + // Charlie's vault should be unchanged + let vault_delta = executed_transaction.account_delta().vault(); + assert_eq!(vault_delta.added_assets().count(), 0); + assert_eq!(vault_delta.removed_assets().count(), 0); + + Ok(()) +} + +#[tokio::test] +async fn pswap_note_creator_reclaim_test() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let usdc_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 1000, Some(50))?; + let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1000, Some(25))?; + + let alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], + )?; + + let mut rng = RandomCoin::new(Word::default()); + let requested_asset = FungibleAsset::new(eth_faucet.id(), 25)?; + let storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(alice.id()) + .build(); + let pswap_note: Note = PswapNote::builder() + .sender(alice.id()) + .storage(storage) + .serial_number(rng.draw_word()) + .note_type(NoteType::Public) + .offered_asset(FungibleAsset::new(usdc_faucet.id(), 50)?) + .build()? + .into(); + builder.add_output_note(RawOutputNote::Full(pswap_note.clone())); + + let mock_chain = builder.build()?; + + let tx_context = mock_chain.build_tx_context(alice.id(), &[pswap_note.id()], &[])?.build()?; + + let executed_transaction = tx_context.execute().await?; + + // Verify: 0 output notes, Alice gets 50 USDC back + let output_notes = executed_transaction.output_notes(); + assert_eq!(output_notes.num_notes(), 0, "Expected 0 output notes for reclaim"); + + let account_delta = executed_transaction.account_delta(); + let vault_delta = account_delta.vault(); + let added_assets: Vec = vault_delta.added_assets().collect(); + + assert_eq!(added_assets.len(), 1, "Alice should receive 1 asset back"); + let usdc_reclaimed = match added_assets[0] { + Asset::Fungible(f) => f, + _ => panic!("Expected fungible USDC asset"), + }; + assert_eq!(usdc_reclaimed.faucet_id(), usdc_faucet.id()); + assert_eq!(usdc_reclaimed.amount(), 50); + + Ok(()) +} + +#[tokio::test] +async fn pswap_note_invalid_input_test() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let usdc_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 1000, Some(50))?; + let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1000, Some(30))?; + + let alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], + )?; + let bob = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), 30)?.into()], + )?; + + let mut rng = RandomCoin::new(Word::default()); + let requested_asset = FungibleAsset::new(eth_faucet.id(), 25)?; + let storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(alice.id()) + .build(); + let pswap_note: Note = PswapNote::builder() + .sender(alice.id()) + .storage(storage) + .serial_number(rng.draw_word()) + .note_type(NoteType::Public) + .offered_asset(FungibleAsset::new(usdc_faucet.id(), 50)?) + .build()? + .into(); + builder.add_output_note(RawOutputNote::Full(pswap_note.clone())); + let mock_chain = builder.build()?; + + // Try to fill with 30 ETH when only 25 is requested - should fail + let mut note_args_map = BTreeMap::new(); + note_args_map.insert(pswap_note.id(), Word::from([Felt::from(30u32), Felt::from(0u32), ZERO, ZERO])); + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[pswap_note.id()], &[])? + .extend_note_args(note_args_map) + .build()?; + + let result = tx_context.execute().await; + assert!( + result.is_err(), + "Transaction should fail when input_amount > requested_asset_total" + ); + + Ok(()) +} + +#[tokio::test] +async fn pswap_note_multiple_partial_fills_test() -> anyhow::Result<()> { + let test_scenarios = vec![ + (5u64, "5 ETH - 20% fill"), + (7, "7 ETH - 28% fill"), + (10, "10 ETH - 40% fill"), + (13, "13 ETH - 52% fill"), + (15, "15 ETH - 60% fill"), + (19, "19 ETH - 76% fill"), + (20, "20 ETH - 80% fill"), + (23, "23 ETH - 92% fill"), + (25, "25 ETH - 100% fill (full)"), + ]; + + for (input_amount, _description) in test_scenarios { + let mut builder = MockChain::builder(); + let usdc_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 1000, Some(150))?; + let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1000, Some(50))?; + + let alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], + )?; + + let bob = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), input_amount)?.into()], + )?; + + let mut rng = RandomCoin::new(Word::default()); + let requested_asset = FungibleAsset::new(eth_faucet.id(), 25)?; + let storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(alice.id()) + .build(); + let pswap_note: Note = PswapNote::builder() + .sender(alice.id()) + .storage(storage) + .serial_number(rng.draw_word()) + .note_type(NoteType::Public) + .offered_asset(FungibleAsset::new(usdc_faucet.id(), 50)?) + .build()? + .into(); + builder.add_output_note(RawOutputNote::Full(pswap_note.clone())); + + let mock_chain = builder.build()?; + + let mut note_args_map = BTreeMap::new(); + note_args_map.insert( + pswap_note.id(), + Word::from([Felt::try_from(input_amount).unwrap(), Felt::from(0u32), ZERO, ZERO]), + ); + + let pswap = PswapNote::try_from(&pswap_note)?; + let offered_out = pswap.calculate_offered_for_requested(input_amount); + let (p2id_note, remainder_pswap) = pswap.execute( + bob.id(), + Some(FungibleAsset::new(eth_faucet.id(), input_amount)?), + None, + )?; + + let mut expected_notes = vec![RawOutputNote::Full(p2id_note)]; + + if let Some(remainder) = remainder_pswap { + expected_notes.push(RawOutputNote::Full(Note::from(remainder))); + } + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[pswap_note.id()], &[])? + .extend_expected_output_notes(expected_notes) + .extend_note_args(note_args_map) + .build()?; + + let executed_transaction = tx_context.execute().await?; + + let output_notes = executed_transaction.output_notes(); + let expected_count = if input_amount < 25 { 2 } else { 1 }; + assert_eq!(output_notes.num_notes(), expected_count); + + // Verify Bob's vault + let vault_delta = executed_transaction.account_delta().vault(); + let added: Vec = vault_delta.added_assets().collect(); + assert_eq!(added.len(), 1); + if let Asset::Fungible(f) = added[0] { + assert_eq!(f.amount(), offered_out); + } + } + + Ok(()) +} + +#[tokio::test] +async fn pswap_note_non_exact_ratio_partial_fill_test() -> anyhow::Result<()> { + let offered_total = 100u64; + let requested_total = 30u64; + let input_amount = 7u64; + + let mut builder = MockChain::builder(); + let usdc_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 10000, Some(1000))?; + let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 10000, Some(100))?; + + let alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), offered_total)?.into()], + )?; + let bob = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), input_amount)?.into()], + )?; + + let mut rng = RandomCoin::new(Word::default()); + let requested_asset = FungibleAsset::new(eth_faucet.id(), requested_total)?; + let storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(alice.id()) + .build(); + let pswap_note: Note = PswapNote::builder() + .sender(alice.id()) + .storage(storage) + .serial_number(rng.draw_word()) + .note_type(NoteType::Public) + .offered_asset(FungibleAsset::new(usdc_faucet.id(), offered_total)?) + .build()? + .into(); + builder.add_output_note(RawOutputNote::Full(pswap_note.clone())); + + let mock_chain = builder.build()?; + + let mut note_args_map = BTreeMap::new(); + note_args_map + .insert(pswap_note.id(), Word::from([Felt::try_from(input_amount).unwrap(), Felt::from(0u32), ZERO, ZERO])); + + let pswap = PswapNote::try_from(&pswap_note)?; + let expected_output = pswap.calculate_offered_for_requested(input_amount); + let (p2id_note, remainder_pswap) = pswap.execute( + bob.id(), + Some(FungibleAsset::new(eth_faucet.id(), input_amount)?), + None, + )?; + let remainder = Note::from(remainder_pswap.expect("partial fill should produce remainder")); + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[pswap_note.id()], &[])? + .extend_expected_output_notes(vec![ + RawOutputNote::Full(p2id_note), + RawOutputNote::Full(remainder), + ]) + .extend_note_args(note_args_map) + .build()?; + + let executed_tx = tx_context.execute().await?; + + let output_notes = executed_tx.output_notes(); + assert_eq!(output_notes.num_notes(), 2); + + let vault_delta = executed_tx.account_delta().vault(); + let added: Vec = vault_delta.added_assets().collect(); + assert_eq!(added.len(), 1); + if let Asset::Fungible(f) = &added[0] { + assert_eq!(f.amount(), expected_output); + } + + Ok(()) +} + +#[tokio::test] +async fn pswap_note_partial_fill_non_integer_ratio_fuzz_test() -> anyhow::Result<()> { + // (offered_usdc, requested_eth, fill_eth) + let test_cases: Vec<(u64, u64, u64)> = vec![ + (23, 20, 7), + (23, 20, 13), + (23, 20, 19), + (17, 13, 5), + (97, 89, 37), + (53, 47, 23), + (7, 5, 3), + (7, 5, 1), + (7, 5, 4), + (89, 55, 21), + (233, 144, 55), + (34, 21, 8), + (50, 97, 30), + (13, 47, 20), + (3, 7, 5), + (101, 100, 50), + (100, 99, 50), + (997, 991, 500), + (1000, 3, 1), + (1000, 3, 2), + (3, 1000, 500), + (9999, 7777, 3333), + (5000, 3333, 1111), + (127, 63, 31), + (255, 127, 63), + (511, 255, 100), + ]; + + for (i, (offered_usdc, requested_eth, fill_eth)) in test_cases.iter().enumerate() { + let remaining_requested = requested_eth - fill_eth; + + let mut builder = MockChain::builder(); + let max_supply = 100_000u64; + + let usdc_faucet = builder.add_existing_basic_faucet( + BASIC_AUTH, + "USDC", + max_supply, + Some(*offered_usdc), + )?; + let eth_faucet = + builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", max_supply, Some(*fill_eth))?; + + let alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), *offered_usdc)?.into()], + )?; + let bob = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), *fill_eth)?.into()], + )?; + + let mut rng = RandomCoin::new(Word::default()); + let requested_asset = FungibleAsset::new(eth_faucet.id(), *requested_eth)?; + let storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(alice.id()) + .build(); + let pswap_note: Note = PswapNote::builder() + .sender(alice.id()) + .storage(storage) + .serial_number(rng.draw_word()) + .note_type(NoteType::Public) + .offered_asset(FungibleAsset::new(usdc_faucet.id(), *offered_usdc)?) + .build()? + .into(); + builder.add_output_note(RawOutputNote::Full(pswap_note.clone())); + + let mock_chain = builder.build()?; + + let mut note_args_map = BTreeMap::new(); + note_args_map + .insert(pswap_note.id(), Word::from([Felt::try_from(*fill_eth).unwrap(), Felt::from(0u32), ZERO, ZERO])); + + let pswap = PswapNote::try_from(&pswap_note)?; + let offered_out = pswap.calculate_offered_for_requested(*fill_eth); + let remaining_offered = offered_usdc - offered_out; + + assert!(offered_out > 0, "Case {}: offered_out must be > 0", i + 1); + assert!(offered_out <= *offered_usdc, "Case {}: offered_out > offered", i + 1); + let (p2id_note, remainder_pswap) = pswap.execute( + bob.id(), + Some(FungibleAsset::new(eth_faucet.id(), *fill_eth)?), + None, + )?; + + let mut expected_notes = vec![RawOutputNote::Full(p2id_note)]; + if remaining_requested > 0 { + let remainder = + Note::from(remainder_pswap.expect("partial fill should produce remainder")); + expected_notes.push(RawOutputNote::Full(remainder)); + } + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[pswap_note.id()], &[])? + .extend_expected_output_notes(expected_notes) + .extend_note_args(note_args_map) + .build()?; + + let executed_tx = tx_context.execute().await.map_err(|e| { + anyhow::anyhow!( + "Case {} failed: {} (offered={}, requested={}, fill={})", + i + 1, + e, + offered_usdc, + requested_eth, + fill_eth + ) + })?; + + let output_notes = executed_tx.output_notes(); + let expected_count = if remaining_requested > 0 { 2 } else { 1 }; + assert_eq!(output_notes.num_notes(), expected_count, "Case {}", i + 1); + + let vault_delta = executed_tx.account_delta().vault(); + let added: Vec = vault_delta.added_assets().collect(); + let removed: Vec = vault_delta.removed_assets().collect(); + assert_eq!(added.len(), 1, "Case {}", i + 1); + if let Asset::Fungible(f) = &added[0] { + assert_eq!(f.amount(), offered_out, "Case {}", i + 1); + } + assert_eq!(removed.len(), 1, "Case {}", i + 1); + if let Asset::Fungible(f) = &removed[0] { + assert_eq!(f.amount(), *fill_eth, "Case {}", i + 1); + } + + assert_eq!(offered_out + remaining_offered, *offered_usdc, "Case {}: conservation", i + 1); + } + + Ok(()) +} + +#[tokio::test] +async fn pswap_note_chained_partial_fills_non_integer_ratio_test() -> anyhow::Result<()> { + let test_chains: Vec<(u64, u64, Vec)> = vec![ + (100, 73, vec![17, 23, 19]), + (53, 47, vec![7, 11, 13, 5]), + (200, 137, vec![41, 37, 29]), + (7, 5, vec![2, 1]), + (1000, 777, vec![100, 200, 150, 100]), + (50, 97, vec![20, 30, 15]), + (89, 55, vec![13, 8, 21]), + (23, 20, vec![3, 5, 4, 3]), + (997, 991, vec![300, 300, 200]), + (3, 2, vec![1]), + ]; + + for (chain_idx, (initial_offered, initial_requested, fills)) in test_chains.iter().enumerate() { + let mut current_offered = *initial_offered; + let mut current_requested = *initial_requested; + let mut total_usdc_to_bob = 0u64; + let mut total_eth_from_bob = 0u64; + // Track serial for remainder chain + let mut rng = RandomCoin::new(Word::default()); + let mut current_serial = rng.draw_word(); + + for (current_swap_count, fill_amount) in fills.iter().enumerate() { + let remaining_requested = current_requested - fill_amount; + + let mut builder = MockChain::builder(); + let max_supply = 100_000u64; + + let usdc_faucet = builder.add_existing_basic_faucet( + BASIC_AUTH, + "USDC", + max_supply, + Some(current_offered), + )?; + let eth_faucet = builder.add_existing_basic_faucet( + BASIC_AUTH, + "ETH", + max_supply, + Some(*fill_amount), + )?; + + let alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), current_offered)?.into()], + )?; + let bob = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), *fill_amount)?.into()], + )?; + + // Build storage and note manually to use the correct serial for chain position + let offered_fungible = + FungibleAsset::new(usdc_faucet.id(), current_offered)?; + let requested_fungible = + FungibleAsset::new(eth_faucet.id(), current_requested)?; + + let pswap_tag = + PswapNote::create_tag(NoteType::Public, &offered_fungible, &requested_fungible); + let offered_asset = Asset::Fungible(offered_fungible); + + let storage = PswapNoteStorage::builder() + .requested_asset(requested_fungible) + .pswap_tag(pswap_tag) + .swap_count(current_swap_count as u16) + .creator_account_id(alice.id()) + .build(); + let note_assets = NoteAssets::new(vec![offered_asset])?; + + // Create note with the correct serial for this chain position + let note_storage = NoteStorage::from(storage); + let recipient = NoteRecipient::new(current_serial, PswapNote::script(), note_storage); + let metadata = NoteMetadata::new(alice.id(), NoteType::Public).with_tag(pswap_tag); + let pswap_note = Note::new(note_assets, metadata, recipient); + + builder.add_output_note(RawOutputNote::Full(pswap_note.clone())); + let mock_chain = builder.build()?; + + let mut note_args_map = BTreeMap::new(); + note_args_map.insert( + pswap_note.id(), + Word::from([Felt::try_from(*fill_amount).unwrap(), Felt::from(0u32), ZERO, ZERO]), + ); + + let pswap = PswapNote::try_from(&pswap_note)?; + let offered_out = pswap.calculate_offered_for_requested(*fill_amount); + let remaining_offered = current_offered - offered_out; + let (p2id_note, remainder_pswap) = pswap.execute( + bob.id(), + Some(FungibleAsset::new(eth_faucet.id(), *fill_amount)?), + None, + )?; + + let mut expected_notes = vec![RawOutputNote::Full(p2id_note)]; + if remaining_requested > 0 { + let remainder = + Note::from(remainder_pswap.expect("partial fill should produce remainder")); + expected_notes.push(RawOutputNote::Full(remainder)); + } + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[pswap_note.id()], &[])? + .extend_expected_output_notes(expected_notes) + .extend_note_args(note_args_map) + .build()?; + + let executed_tx = tx_context.execute().await.map_err(|e| { + anyhow::anyhow!( + "Chain {} fill {} failed: {} (offered={}, requested={}, fill={})", + chain_idx + 1, + current_swap_count + 1, + e, + current_offered, + current_requested, + fill_amount + ) + })?; + + let output_notes = executed_tx.output_notes(); + let expected_count = if remaining_requested > 0 { 2 } else { 1 }; + assert_eq!( + output_notes.num_notes(), + expected_count, + "Chain {} fill {}", + chain_idx + 1, + current_swap_count + 1 + ); + + let vault_delta = executed_tx.account_delta().vault(); + let added: Vec = vault_delta.added_assets().collect(); + assert_eq!(added.len(), 1, "Chain {} fill {}", chain_idx + 1, current_swap_count + 1); + if let Asset::Fungible(f) = &added[0] { + assert_eq!( + f.amount(), + offered_out, + "Chain {} fill {}: Bob should get {} USDC", + chain_idx + 1, + current_swap_count + 1, + offered_out + ); + } + + // Update state for next fill + total_usdc_to_bob += offered_out; + total_eth_from_bob += fill_amount; + current_offered = remaining_offered; + current_requested = remaining_requested; + // Remainder serial: [0] + 1 (matching MASM LE orientation) + current_serial = Word::from([ + current_serial[0] + ONE, + current_serial[1], + current_serial[2], + current_serial[3], + ]); + } + + // Verify conservation + let total_fills: u64 = fills.iter().sum(); + assert_eq!(total_eth_from_bob, total_fills, "Chain {}: ETH conservation", chain_idx + 1); + assert_eq!( + total_usdc_to_bob + current_offered, + *initial_offered, + "Chain {}: USDC conservation", + chain_idx + 1 + ); + } + + Ok(()) +} + +/// Test that PswapNote builder + try_from + execute roundtrips correctly +#[test] +fn compare_pswap_create_output_notes_vs_test_helper() { + let mut builder = MockChain::builder(); + let usdc_faucet = + builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 1000, Some(150)).unwrap(); + let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1000, Some(50)).unwrap(); + let alice = builder + .add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), 50).unwrap().into()], + ) + .unwrap(); + let bob = builder + .add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(eth_faucet.id(), 25).unwrap().into()], + ) + .unwrap(); + + // Create swap note using PswapNote builder + let mut rng = RandomCoin::new(Word::default()); + let requested_asset = FungibleAsset::new(eth_faucet.id(), 25).unwrap(); + let storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(alice.id()) + .build(); + let pswap_note: Note = PswapNote::builder() + .sender(alice.id()) + .storage(storage) + .serial_number(rng.draw_word()) + .note_type(NoteType::Public) + .offered_asset(FungibleAsset::new(usdc_faucet.id(), 50).unwrap()) + .build() + .unwrap() + .into(); + + // Roundtrip: try_from -> execute -> verify outputs + let pswap = PswapNote::try_from(&pswap_note).unwrap(); + + // Verify roundtripped PswapNote preserves key fields + assert_eq!(pswap.sender(), alice.id(), "Sender mismatch after roundtrip"); + assert_eq!(pswap.note_type(), NoteType::Public, "Note type mismatch after roundtrip"); + assert_eq!(pswap.storage().requested_asset_amount(), 25, "Requested amount mismatch"); + assert_eq!(pswap.storage().swap_count(), 0, "Swap count should be 0"); + assert_eq!(pswap.storage().creator_account_id(), alice.id(), "Creator ID mismatch"); + + // Full fill: should produce P2ID note, no remainder + let (p2id_note, remainder) = + pswap.execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), 25).unwrap()), None).unwrap(); + assert!(remainder.is_none(), "Full fill should not produce remainder"); + + // Verify P2ID note properties + assert_eq!(p2id_note.metadata().sender(), bob.id(), "P2ID sender should be consumer"); + assert_eq!(p2id_note.metadata().note_type(), NoteType::Public, "P2ID note type mismatch"); + assert_eq!(p2id_note.assets().num_assets(), 1, "P2ID should have 1 asset"); + if let Asset::Fungible(f) = p2id_note.assets().iter().next().unwrap() { + assert_eq!(f.faucet_id(), eth_faucet.id(), "P2ID asset faucet mismatch"); + assert_eq!(f.amount(), 25, "P2ID asset amount mismatch"); + } else { + panic!("Expected fungible asset in P2ID note"); + } + + // Partial fill: should produce P2ID note + remainder + let (p2id_partial, remainder_partial) = + pswap.execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), 10).unwrap()), None).unwrap(); + let remainder_pswap = remainder_partial.expect("Partial fill should produce remainder"); + + assert_eq!(p2id_partial.assets().num_assets(), 1); + if let Asset::Fungible(f) = p2id_partial.assets().iter().next().unwrap() { + assert_eq!(f.faucet_id(), eth_faucet.id()); + assert_eq!(f.amount(), 10); + } + + // Verify remainder properties + assert_eq!(remainder_pswap.storage().swap_count(), 1, "Remainder swap count should be 1"); + assert_eq!( + remainder_pswap.storage().creator_account_id(), + alice.id(), + "Remainder creator should be Alice" + ); + let remaining_requested = remainder_pswap.storage().requested_asset_amount(); + assert_eq!(remaining_requested, 15, "Remaining requested should be 15"); +} + +/// Test that PswapNote::parse_inputs roundtrips correctly +#[test] +fn pswap_parse_inputs_roundtrip() { + let mut builder = MockChain::builder(); + let usdc_faucet = + builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 1000, Some(150)).unwrap(); + let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1000, Some(50)).unwrap(); + let alice = builder + .add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), 50).unwrap().into()], + ) + .unwrap(); + + let mut rng = RandomCoin::new(Word::default()); + let requested_asset = FungibleAsset::new(eth_faucet.id(), 25).unwrap(); + let storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(alice.id()) + .build(); + let pswap_note: Note = PswapNote::builder() + .sender(alice.id()) + .storage(storage) + .serial_number(rng.draw_word()) + .note_type(NoteType::Public) + .offered_asset(FungibleAsset::new(usdc_faucet.id(), 50).unwrap()) + .build() + .unwrap() + .into(); + + let storage = pswap_note.recipient().storage(); + let items = storage.items(); + + let parsed = PswapNoteStorage::try_from(items).unwrap(); + + assert_eq!(parsed.creator_account_id(), alice.id(), "Creator ID roundtrip failed!"); + assert_eq!(parsed.swap_count(), 0, "Swap count should be 0"); + + // Verify requested amount from value word + assert_eq!(parsed.requested_asset_amount(), 25, "Requested amount should be 25"); +} + +/// Test that a PSWAP note can be consumed by a network account (full fill, no note_args). +/// +/// Alice (local) creates a PSWAP note offering 50 USDC for 25 ETH. A network account with a +/// BasicWallet consumes it. Since no note_args are provided, the script defaults to a full fill. +#[tokio::test] +async fn pswap_note_network_account_full_fill_test() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let usdc_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 1000, Some(150))?; + let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1000, Some(50))?; + + let alice = builder.add_existing_wallet_with_assets( + BASIC_AUTH, + [FungibleAsset::new(usdc_faucet.id(), 50)?.into()], + )?; + + // Create a network account with BasicWallet that holds 25 ETH + let seed: [u8; 32] = builder.rng_mut().draw_word().into(); + let network_consumer = builder.add_account_from_builder( + BASIC_AUTH, + Account::builder(seed) + .storage_mode(AccountStorageMode::Network) + .with_component(BasicWallet) + .with_assets([FungibleAsset::new(eth_faucet.id(), 25)?.into()]), + miden_testing::AccountState::Exists, + )?; + + let requested_asset = FungibleAsset::new(eth_faucet.id(), 25)?; + + let mut rng = RandomCoin::new(Word::default()); + let storage = PswapNoteStorage::builder() + .requested_asset(requested_asset) + .creator_account_id(alice.id()) + .build(); + let pswap_note: Note = PswapNote::builder() + .sender(alice.id()) + .storage(storage) + .serial_number(rng.draw_word()) + .note_type(NoteType::Public) + .offered_asset(FungibleAsset::new(usdc_faucet.id(), 50)?) + .build()? + .into(); + builder.add_output_note(RawOutputNote::Full(pswap_note.clone())); + + let mut mock_chain = builder.build()?; + + // No note_args — simulates a network transaction where args default to [0, 0, 0, 0]. + // The PSWAP script defaults to a full fill when both input and inflight are 0. + let pswap = PswapNote::try_from(&pswap_note)?; + let p2id_note = pswap.execute_full_fill_network(network_consumer.id())?; + + let tx_context = mock_chain + .build_tx_context(network_consumer.id(), &[pswap_note.id()], &[])? + .extend_expected_output_notes(vec![RawOutputNote::Full(p2id_note.clone())]) + .build()?; + + let executed_transaction = tx_context.execute().await?; + + // Verify: 1 P2ID note with 25 ETH for Alice + let output_notes = executed_transaction.output_notes(); + assert_eq!(output_notes.num_notes(), 1, "Expected exactly 1 P2ID note"); + + let actual_recipient = output_notes.get_note(0).recipient_digest(); + let expected_recipient = p2id_note.recipient().digest(); + assert_eq!(actual_recipient, expected_recipient, "Recipient mismatch"); + + let p2id_assets = output_notes.get_note(0).assets(); + assert_eq!(p2id_assets.num_assets(), 1); + if let Asset::Fungible(f) = p2id_assets.iter().next().unwrap() { + assert_eq!(f.faucet_id(), eth_faucet.id()); + assert_eq!(f.amount(), 25); + } else { + panic!("Expected fungible asset in P2ID note"); + } + + // Verify network consumer's vault delta: +50 USDC, -25 ETH + let vault_delta = executed_transaction.account_delta().vault(); + let added: Vec = vault_delta.added_assets().collect(); + let removed: Vec = vault_delta.removed_assets().collect(); + + assert_eq!(added.len(), 1); + assert_eq!(removed.len(), 1); + if let Asset::Fungible(f) = &added[0] { + assert_eq!(f.faucet_id(), usdc_faucet.id()); + assert_eq!(f.amount(), 50); + } + if let Asset::Fungible(f) = &removed[0] { + assert_eq!(f.faucet_id(), eth_faucet.id()); + assert_eq!(f.amount(), 25); + } + + mock_chain.add_pending_executed_transaction(&executed_transaction)?; + let _ = mock_chain.prove_next_block(); + + Ok(()) +}