diff --git a/Cargo.lock b/Cargo.lock index 01f569efe3..127501d4af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -281,6 +281,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" @@ -605,6 +630,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" @@ -1137,6 +1196,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" @@ -1777,6 +1842,7 @@ version = "0.14.0-alpha.1" dependencies = [ "anyhow", "assert_matches", + "bon", "fs-err", "miden-assembly", "miden-core", @@ -3156,6 +3222,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 c5b384107a..a474dd6939 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ miden-verifier = { default-features = false, version = "0.21" } # External dependencies anyhow = { default-features = false, features = ["backtrace", "std"], version = "1.0" } +bon = { default-features = false, version = "3" } assert_matches = { default-features = false, version = "1.5" } fs-err = { default-features = false, version = "3" } primitive-types = { default-features = false, version = "0.14" } 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..18b1c1d384 --- /dev/null +++ b/crates/miden-standards/asm/standards/notes/pswap.masm @@ -0,0 +1,653 @@ +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 + +# 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 +# - TokenId: addresses 0x002D - 0x0030 +# - Full Word (word-aligned): addresses 0x0018+ + +# PSWAP Note Storage (18 items loaded at address 0) +# REQUESTED_ASSET_WORD_INPUT is the base address of an 8-cell block: +# addresses 0x0000-0x0003 = ASSET_KEY, 0x0004-0x0007 = ASSET_VALUE +const REQUESTED_ASSET_WORD_INPUT = 0x0000 +const REQUESTED_ASSET_VALUE_INPUT = 0x0004 +const SWAPP_TAG_INPUT = 0x0008 +const P2ID_TAG_INPUT = 0x0009 +const SWAPP_COUNT_INPUT = 0x000C +const SWAPP_CREATOR_PREFIX_INPUT = 0x0010 +const SWAPP_CREATOR_SUFFIX_INPUT = 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 + +# TokenId Memory Addresses +const TOKEN_OFFERED_ID_PREFIX = 0x002D +const TOKEN_OFFERED_ID_SUFFIX = 0x002E +const TOKEN_REQUESTED_ID_PREFIX = 0x002F +const TOKEN_REQUESTED_ID_SUFFIX = 0x0030 + +# 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 SWAPP_NOTE_IDX = 0x0080 +const NOTE_TYPE = 0x0084 + +# ERRORS +# ================================================================================================= + +# PSWAP script expects exactly 18 note storage items +const ERR_PSWAP_WRONG_NUMBER_OF_INPUTS="PSWAP wrong number of inputs" + +# PSWAP script requires exactly one note asset +const ERR_PSWAP_WRONG_NUMBER_OF_ASSETS="PSWAP wrong number of assets" + +# 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. +#! +#! Metadata layout: [NOTE_ATTACHMENT(4), METADATA_HEADER(4)] +#! METADATA_HEADER[0] = sender_suffix_and_note_type (note_type in bits 0-1) +#! +#! Inputs: [] +#! Outputs: [] +#! +proc extract_note_type + exec.active_note::get_metadata + # => [NOTE_ATTACHMENT(4), METADATA_HEADER(4)] + dropw + # => [METADATA_HEADER] = [hdr3, hdr2, hdr1, hdr0] + # hdr[3] = sender_id_prefix (top) + # hdr[0] = sender_suffix_and_note_type (bottom, contains note_type in bits 0-1) + # Keep hdr0 (bottom), drop hdr3/hdr2/hdr1 from top + movdn.3 drop drop drop + # => [hdr0 = 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 (SWAPP_CREATOR_SUFFIX/PREFIX_INPUT), +#! 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] at word-aligned temp address 4000 + mem_load.SWAPP_CREATOR_SUFFIX_INPUT mem_store.4000 + mem_load.SWAPP_CREATOR_PREFIX_INPUT mem_store.4001 + # => [SERIAL_NUM, SCRIPT_ROOT] + + # note::build_recipient: [storage_ptr, num_storage_items, SERIAL_NUM, SCRIPT_ROOT] => [RECIPIENT] + push.2.4000 + # => [storage_ptr=4000, 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 + # 1. Load P2ID script root (miden-standards v0.14.0-beta.2, P2idNote::script_root()) + push.17577144666381623537.251255385102954082.10949974299239761467.7391338276508122752 + # => [P2ID_SCRIPT_ROOT] + + # 2. Increment swap count (ensures unique serial per P2ID note in chained fills) + mem_load.SWAPP_COUNT_INPUT add.1 mem_store.SWAPP_COUNT_INPUT + # => [P2ID_SCRIPT_ROOT] + + # 3. Load swap count word from memory + # mem_loadw_le: Word[0]=mem[addr] on top + padw mem_loadw_le.SWAPP_COUNT_INPUT + # => [SWAP_COUNT_WORD, P2ID_SCRIPT_ROOT] + + # 4. Get serial number from active note + exec.active_note::get_serial_number + # => [SERIAL_NUM, SWAP_COUNT_WORD, P2ID_SCRIPT_ROOT] + + # 5. 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] + + # 6. Build P2ID recipient + exec.build_p2id_recipient_hash + # => [P2ID_RECIPIENT] + + # 7. Create output note (note_type inherited from active note metadata) + mem_load.NOTE_TYPE + # => [note_type, P2ID_RECIPIENT] + + mem_load.P2ID_TAG_INPUT + # => [tag, note_type, RECIPIENT] + + exec.output_note::create + # => [note_idx] + + mem_store.P2ID_NOTE_IDX + # => [] + + # 8. Set attachment: aux = input_amount + inflight_amount (total fill) + 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, ATTACHMENT] + + exec.output_note::set_word_attachment + # => [] + + # 9. Move input_amount from consumer's vault to P2ID note (if > 0) + mem_load.AMT_REQUESTED_IN dup push.0 neq + 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)] + + # Create fungible asset: expects [suffix, prefix, amount] with suffix on top + mem_load.AMT_REQUESTED_IN + mem_load.TOKEN_REQUESTED_ID_PREFIX + mem_load.TOKEN_REQUESTED_ID_SUFFIX + # => [suffix, prefix, amount, note_idx, pad(7)] + + exec.asset::create_fungible_asset + # => [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] + + call.wallet::move_asset_to_note + # => [pad(16)] + + dropw dropw dropw dropw + # => [] + else + drop + end + + # 10. Add inflight_amount directly to P2ID note (no vault debit, if > 0) + mem_load.AMT_REQUESTED_INFLIGHT dup push.0 neq + if.true + drop + + mem_load.P2ID_NOTE_IDX + # => [note_idx] + + mem_load.AMT_REQUESTED_INFLIGHT + mem_load.TOKEN_REQUESTED_ID_PREFIX + mem_load.TOKEN_REQUESTED_ID_SUFFIX + # => [suffix, prefix, amount, note_idx] + + exec.asset::create_fungible_asset + # => [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_INPUT + # => [] + + # 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.SWAPP_TAG_INPUT + + exec.output_note::create + # => [note_idx] + + mem_store.SWAPP_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.SWAPP_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.SWAPP_NOTE_IDX + # => [note_idx] + + mem_load.AMT_OFFERED mem_load.AMT_OFFERED_OUT sub + # => [remainder_amount, note_idx] + + mem_load.TOKEN_OFFERED_ID_PREFIX + mem_load.TOKEN_OFFERED_ID_SUFFIX + # => [suffix, prefix, remainder_amount, note_idx] + + exec.asset::create_fungible_asset + # => [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.SWAPP_CREATOR_SUFFIX_INPUT mem_load.SWAPP_CREATOR_PREFIX_INPUT + # => [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] + + # Extract offered faucet IDs from key + exec.asset::key_into_faucet_id + # => [faucet_id_suffix, faucet_id_prefix, ASSET_VALUE] + mem_store.TOKEN_OFFERED_ID_SUFFIX + mem_store.TOKEN_OFFERED_ID_PREFIX + # => [ASSET_VALUE] + dropw + # => [] + + # Load requested asset from note storage (ASSET_KEY + ASSET_VALUE, 8 cells) + push.REQUESTED_ASSET_WORD_INPUT 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] + + # Extract requested faucet IDs from key + exec.asset::key_into_faucet_id + # => [faucet_id_suffix, faucet_id_prefix, ASSET_VALUE] + mem_store.TOKEN_REQUESTED_ID_SUFFIX + mem_store.TOKEN_REQUESTED_ID_PREFIX + # => [ASSET_VALUE] + dropw + # => [] + + # 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) + + 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 82b94c6a68..87c6795368 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; @@ -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..66f248e74e --- /dev/null +++ b/crates/miden-standards/src/note/pswap.rs @@ -0,0 +1,929 @@ +use alloc::vec; + +use miden_protocol::Hasher; +use miden_protocol::account::AccountId; +use miden_protocol::assembly::Path; +use miden_protocol::asset::{Asset, FungibleAsset}; +use miden_protocol::crypto::rand::FeltRng; +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, 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]` | P2ID 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)] +pub struct PswapNoteStorage { + requested_key: Word, + requested_value: Word, + pswap_tag: NoteTag, + p2id_tag: NoteTag, + swap_count: u64, + creator_account_id: AccountId, +} + +impl PswapNoteStorage { + // CONSTANTS + // -------------------------------------------------------------------------------------------- + + /// Expected number of storage items for the PSWAP note. + pub const NUM_STORAGE_ITEMS: usize = 18; + + // CONSTRUCTORS + // -------------------------------------------------------------------------------------------- + + /// Creates storage for a new PSWAP note. + /// + /// The PSWAP tag is set to a placeholder and will be computed when the note is + /// converted into a [`Note`] via [`From`]. Swap count starts at 0. + pub fn new(requested_asset: Asset, creator_account_id: AccountId) -> Self { + let p2id_tag = NoteTag::with_account_target(creator_account_id); + Self { + requested_key: requested_asset.to_key_word(), + requested_value: requested_asset.to_value_word(), + pswap_tag: NoteTag::new(0), + p2id_tag, + swap_count: 0, + creator_account_id, + } + } + + /// Creates storage with all fields specified explicitly. + pub fn from_parts( + requested_key: Word, + requested_value: Word, + pswap_tag: NoteTag, + p2id_tag: NoteTag, + swap_count: u64, + creator_account_id: AccountId, + ) -> Self { + Self { + requested_key, + requested_value, + pswap_tag, + p2id_tag, + swap_count, + creator_account_id, + } + } + + /// 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 + // -------------------------------------------------------------------------------------------- + + pub fn requested_key(&self) -> Word { + self.requested_key + } + + pub fn requested_value(&self) -> Word { + self.requested_value + } + + pub fn pswap_tag(&self) -> NoteTag { + self.pswap_tag + } + + pub fn p2id_tag(&self) -> NoteTag { + self.p2id_tag + } + + /// Number of times this note has been partially filled and re-created. + pub fn swap_count(&self) -> u64 { + self.swap_count + } + + pub fn creator_account_id(&self) -> AccountId { + self.creator_account_id + } + + /// Reconstructs the requested [`Asset`] from the stored key and value words. + /// + /// # Errors + /// + /// Returns an error if the faucet ID or amount stored in the key/value words is invalid. + pub fn requested_asset(&self) -> Result { + let faucet_id = self.requested_faucet_id()?; + let amount = self.requested_amount(); + Ok(Asset::Fungible(FungibleAsset::new(faucet_id, amount).map_err(|e| { + NoteError::other_with_source("failed to create requested asset", e) + })?)) + } + + /// Extracts the faucet ID of the requested asset from the key word. + pub fn requested_faucet_id(&self) -> Result { + AccountId::try_from_elements(self.requested_key[2], self.requested_key[3]).map_err(|e| { + NoteError::other_with_source("failed to parse faucet ID from key", e) + }) + } + + /// Extracts the requested token amount from the value word. + pub fn requested_amount(&self) -> u64 { + self.requested_value[0].as_canonical_u64() + } +} + +/// Serializes [`PswapNoteStorage`] into an 18-element [`NoteStorage`]. +impl From for NoteStorage { + fn from(storage: PswapNoteStorage) -> Self { + let inputs = vec![ + // ASSET_KEY [0-3] + storage.requested_key[0], + storage.requested_key[1], + storage.requested_key[2], + storage.requested_key[3], + // ASSET_VALUE [4-7] + storage.requested_value[0], + storage.requested_value[1], + storage.requested_value[2], + storage.requested_value[3], + // Tags [8-9] + Felt::new(u32::from(storage.pswap_tag) as u64), + Felt::new(u32::from(storage.p2id_tag) as u64), + // Padding [10-11] + ZERO, + ZERO, + // Swap count [12-15] + Felt::new(storage.swap_count), + ZERO, + ZERO, + ZERO, + // Creator ID [16-17] + storage.creator_account_id.prefix().as_felt(), + storage.creator_account_id.suffix(), + ]; + NoteStorage::new(inputs) + .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(inputs: &[Felt]) -> Result { + if inputs.len() != Self::NUM_STORAGE_ITEMS { + return Err(NoteError::InvalidNoteStorageLength { + expected: Self::NUM_STORAGE_ITEMS, + actual: inputs.len(), + }); + } + + let requested_key = Word::from([inputs[0], inputs[1], inputs[2], inputs[3]]); + let requested_value = Word::from([inputs[4], inputs[5], inputs[6], inputs[7]]); + let pswap_tag = NoteTag::new(inputs[8].as_canonical_u64() as u32); + let p2id_tag = NoteTag::new(inputs[9].as_canonical_u64() as u32); + let swap_count = inputs[12].as_canonical_u64(); + + let creator_account_id = + AccountId::try_from_elements(inputs[17], inputs[16]).map_err(|e| { + NoteError::other_with_source("failed to parse creator account ID", e) + })?; + + Ok(Self { + requested_key, + requested_value, + pswap_tag, + p2id_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 P2ID payback note. +#[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, + + assets: NoteAssets, + + #[builder(default)] + attachment: NoteAttachment, +} + +impl PswapNote { + // CONSTANTS + // -------------------------------------------------------------------------------------------- + + /// Expected number of storage items for the PSWAP note. + pub const NUM_STORAGE_ITEMS: usize = PswapNoteStorage::NUM_STORAGE_ITEMS; + + // 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() + } + + pub fn sender(&self) -> AccountId { + self.sender + } + + pub fn storage(&self) -> &PswapNoteStorage { + &self.storage + } + + pub fn serial_number(&self) -> Word { + self.serial_number + } + + pub fn note_type(&self) -> NoteType { + self.note_type + } + + pub fn assets(&self) -> &NoteAssets { + &self.assets + } + + pub fn attachment(&self) -> &NoteAttachment { + &self.attachment + } + + // BUILDERS + // -------------------------------------------------------------------------------------------- + + /// Creates a PSWAP note offering `offered_asset` in exchange for `requested_asset`. + /// + /// # Errors + /// + /// Returns an error if the two assets share the same faucet or if asset construction fails. + pub fn create( + creator_account_id: AccountId, + offered_asset: Asset, + requested_asset: Asset, + note_type: NoteType, + note_attachment: NoteAttachment, + rng: &mut R, + ) -> Result { + if offered_asset.faucet_id().prefix() == requested_asset.faucet_id().prefix() { + return Err(NoteError::other( + "offered and requested assets must have different faucets", + )); + } + + let storage = PswapNoteStorage::new(requested_asset, creator_account_id); + let pswap = PswapNote::builder() + .sender(creator_account_id) + .storage(storage) + .serial_number(rng.draw_word()) + .note_type(note_type) + .assets(NoteAssets::new(vec![offered_asset])?) + .attachment(note_attachment) + .build_internal(); + + Ok(Note::from(pswap)) + } + + // INSTANCE METHODS + // -------------------------------------------------------------------------------------------- + + /// Executes the swap, producing the output notes for a given fill. + /// + /// `input_amount` is debited from the consumer's vault; `inflight_amount` arrives + /// from another note in the same transaction (cross-swap). Their sum is the total fill. + /// + /// Returns `(p2id_payback_note, Option)`. The remainder is + /// `None` when the fill equals the total requested amount (full fill). + pub fn execute( + &self, + consumer_account_id: AccountId, + input_amount: u64, + inflight_amount: u64, + ) -> Result<(Note, Option), NoteError> { + let fill_amount = input_amount + inflight_amount; + + let requested_faucet_id = self.storage.requested_faucet_id()?; + let total_requested_amount = self.storage.requested_amount(); + + // Ensure offered asset exists and is fungible + if self.assets.num_assets() != 1 { + return Err(NoteError::other("Swap note must have exactly 1 offered asset")); + } + let offered_asset = + self.assets.iter().next().ok_or(NoteError::other("No offered asset found"))?; + let (offered_faucet_id, total_offered_amount) = match offered_asset { + Asset::Fungible(fa) => (fa.faucet_id(), fa.amount()), + _ => return Err(NoteError::other("Non-fungible offered asset not supported")), + }; + + // 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. + 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; + + // Build the P2ID payback note + let payback_asset = + Asset::Fungible(FungibleAsset::new(requested_faucet_id, fill_amount).map_err(|e| { + NoteError::other_with_source("failed to create P2ID asset", e) + })?); + + let aux_word = Word::from([Felt::new(fill_amount), ZERO, ZERO, ZERO]); + + let p2id_note = self.build_p2id_payback_note( + consumer_account_id, + payback_asset, + aux_word, + )?; + + // 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 = + Asset::Fungible(FungibleAsset::new(offered_faucet_id, remaining_offered).map_err( + |e| NoteError::other_with_source("failed to create remainder asset", e), + )?); + + Some(self.build_remainder_pswap_note( + consumer_account_id, + remaining_offered_asset, + remaining_requested, + offered_amount_for_fill, + )?) + } else { + None + }; + + Ok((p2id_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, + ) -> Result { + let total_requested = self.storage.requested_amount(); + + let offered_asset = self + .assets + .iter() + .next() + .ok_or(NoteError::other("No offered asset found"))?; + let total_offered = match offered_asset { + Asset::Fungible(fa) => fa.amount(), + _ => return Err(NoteError::other("Non-fungible offered asset not supported")), + }; + + Ok(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 build_tag( + note_type: NoteType, + offered_asset: &Asset, + requested_asset: &Asset, + ) -> 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`. + pub 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 P2ID payback note 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)`. + pub fn build_p2id_payback_note( + &self, + consumer_account_id: AccountId, + payback_asset: Asset, + aux_word: Word, + ) -> Result { + let p2id_tag = self.storage.p2id_tag(); + // Derive P2ID serial matching PSWAP.masm + let swap_count_word = + Word::from([Felt::new(self.storage.swap_count + 1), ZERO, ZERO, ZERO]); + let p2id_serial_digest = + Hasher::merge(&[swap_count_word.into(), self.serial_number.into()]); + let p2id_serial_num: Word = Word::from(p2id_serial_digest); + + // P2ID recipient targets the creator + let recipient = + P2idNoteStorage::new(self.storage.creator_account_id).into_recipient(p2id_serial_num); + + let attachment = NoteAttachment::new_word(NoteAttachmentScheme::none(), aux_word); + + let p2id_assets = NoteAssets::new(vec![payback_asset])?; + let p2id_metadata = NoteMetadata::new(consumer_account_id, self.note_type) + .with_tag(p2id_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`). + pub fn build_remainder_pswap_note( + &self, + consumer_account_id: AccountId, + remaining_offered_asset: Asset, + remaining_requested_amount: u64, + offered_amount_for_fill: u64, + ) -> Result { + let requested_faucet_id = self.storage.requested_faucet_id()?; + let remaining_requested_asset = Asset::Fungible( + FungibleAsset::new(requested_faucet_id, remaining_requested_amount).map_err(|e| { + NoteError::other_with_source("failed to create remaining requested asset", e) + })?, + ); + + let key_word = remaining_requested_asset.to_key_word(); + let value_word = remaining_requested_asset.to_value_word(); + + let new_storage = PswapNoteStorage::from_parts( + key_word, + value_word, + self.storage.pswap_tag, + self.storage.p2id_tag, + self.storage.swap_count + 1, + self.storage.creator_account_id, + ); + + // Remainder serial: increment top element (matching MASM add.1 on Word[0]) + let remainder_serial_num = Word::from([ + Felt::new(self.serial_number[0].as_canonical_u64() + 1), + self.serial_number[1], + self.serial_number[2], + self.serial_number[3], + ]); + + let aux_word = Word::from([Felt::new(offered_amount_for_fill), ZERO, ZERO, ZERO]); + let attachment = NoteAttachment::new_word(NoteAttachmentScheme::none(), aux_word); + + let assets = NoteAssets::new(vec![remaining_offered_asset])?; + + Ok(PswapNote { + sender: consumer_account_id, + storage: new_storage, + serial_number: remainder_serial_num, + note_type: self.note_type, + assets, + attachment, + }) + } +} + +// CONVERSIONS +// ================================================================================================ + +/// Converts a [`PswapNote`] into a protocol [`Note`], computing the final PSWAP tag. +impl From for Note { + fn from(pswap: PswapNote) -> Self { + let offered_asset = pswap + .assets + .iter() + .next() + .expect("PswapNote must have an offered asset"); + let requested_asset = pswap + .storage + .requested_asset() + .expect("PswapNote must have a valid requested asset"); + let tag = PswapNote::build_tag(pswap.note_type, &offered_asset, &requested_asset); + + let storage = pswap.storage.with_pswap_tag(tag); + let recipient = storage.into_recipient(pswap.serial_number); + + let metadata = NoteMetadata::new(pswap.sender, pswap.note_type) + .with_tag(tag) + .with_attachment(pswap.attachment); + + Note::new(pswap.assets, metadata, recipient) + } +} + +impl From<&PswapNote> for Note { + fn from(pswap: &PswapNote) -> Self { + Note::from(pswap.clone()) + } +} + +/// 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 { + let storage = PswapNoteStorage::try_from(note.recipient().storage().items())?; + + Ok(Self { + sender: note.metadata().sender(), + storage, + serial_number: note.recipient().serial_num(), + note_type: note.metadata().note_type(), + assets: note.assets().clone(), + attachment: note.metadata().attachment().clone(), + }) + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use miden_protocol::account::{AccountId, AccountIdVersion, AccountStorageMode, AccountType}; + use miden_protocol::asset::FungibleAsset; + + 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 = Asset::Fungible(FungibleAsset::new(offered_faucet_id, 1000).unwrap()); + let requested_asset = + Asset::Fungible(FungibleAsset::new(requested_faucet_id, 500).unwrap()); + + use miden_protocol::crypto::rand::RpoRandomCoin; + let mut rng = RpoRandomCoin::new(Word::default()); + + let script = PswapNote::script(); + assert!(script.root() != Word::default(), "Script root should not be zero"); + + let note = PswapNote::create( + creator_id, + offered_asset, + requested_asset, + NoteType::Public, + NoteAttachment::default(), + &mut rng, + ); + + assert!(note.is_ok(), "Note creation should succeed"); + let note = note.unwrap(); + + 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(), + PswapNote::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 = Asset::Fungible(FungibleAsset::new(offered_faucet_id, 1000).unwrap()); + let requested_asset = + Asset::Fungible(FungibleAsset::new(requested_faucet_id, 500).unwrap()); + + use miden_protocol::crypto::rand::{FeltRng, RpoRandomCoin}; + let mut rng = RpoRandomCoin::new(Word::default()); + + let storage = PswapNoteStorage::new(requested_asset, creator_id); + let pswap = PswapNote::builder() + .sender(creator_id) + .storage(storage) + .serial_number(rng.draw_word()) + .note_type(NoteType::Public) + .assets(NoteAssets::new(vec![offered_asset]).unwrap()) + .build_internal(); + + assert_eq!(pswap.sender(), creator_id); + assert_eq!(pswap.note_type(), NoteType::Public); + assert_eq!(pswap.assets().num_assets(), 1); + + // 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(), + PswapNote::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 = Asset::Fungible( + FungibleAsset::new( + AccountId::dummy( + offered_faucet_bytes, + AccountIdVersion::Version0, + AccountType::FungibleFaucet, + AccountStorageMode::Public, + ), + 100, + ) + .unwrap(), + ); + let requested_asset = Asset::Fungible( + FungibleAsset::new( + AccountId::dummy( + requested_faucet_bytes, + AccountIdVersion::Version0, + AccountType::FungibleFaucet, + AccountStorageMode::Public, + ), + 200, + ) + .unwrap(), + ); + + let tag = PswapNote::build_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 inputs = 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::new(0xC0000000), // pswap_tag + Felt::new(0x80000001), // p2id_tag + ZERO, + ZERO, + Felt::new(3), // swap_count + ZERO, + ZERO, + ZERO, + creator_id.prefix().as_felt(), + creator_id.suffix(), + ]; + + let parsed = PswapNoteStorage::try_from(inputs.as_slice()).unwrap(); + assert_eq!(parsed.swap_count(), 3); + assert_eq!(parsed.creator_account_id(), creator_id); + assert_eq!( + parsed.requested_key(), + Word::from([key_word[0], key_word[1], key_word[2], key_word[3]]) + ); + assert_eq!(parsed.requested_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 = Asset::Fungible(FungibleAsset::new(faucet_id, 500).unwrap()); + let storage = PswapNoteStorage::new(requested_asset, creator_id); + + // 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_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..87cc103fee --- /dev/null +++ b/crates/miden-testing/tests/scripts/pswap.rs @@ -0,0 +1,1053 @@ +use std::collections::BTreeMap; + +use miden_protocol::account::auth::AuthScheme; +use miden_protocol::asset::{Asset, FungibleAsset}; +use miden_protocol::crypto::rand::{FeltRng, RpoRandomCoin}; +use miden_protocol::note::{ + Note, NoteAssets, NoteAttachment, NoteMetadata, NoteRecipient, NoteStorage, NoteTag, NoteType, +}; +use miden_protocol::transaction::RawOutputNote; +use miden_protocol::{Felt, Word, ZERO}; +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 = Asset::Fungible(FungibleAsset::new(usdc_faucet.id(), 50)?); + let requested_asset = Asset::Fungible(FungibleAsset::new(eth_faucet.id(), 25)?); + + let mut rng = RpoRandomCoin::new(Word::default()); + let swap_note = PswapNote::create( + alice.id(), + offered_asset, + requested_asset, + NoteType::Public, + NoteAttachment::default(), + &mut rng, + )?; + builder.add_output_note(RawOutputNote::Full(swap_note.clone())); + + let mut mock_chain = builder.build()?; + + let mut note_args_map = BTreeMap::new(); + note_args_map.insert( + swap_note.id(), + Word::from([Felt::new(25), Felt::new(0), ZERO, ZERO]), + ); + + let pswap = PswapNote::try_from(&swap_note)?; + let (p2id_note, _remainder) = pswap.execute(bob.id(), 25, 0)?; + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[swap_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 = Asset::Fungible(FungibleAsset::new(usdc_faucet.id(), 50)?); + let requested_asset = Asset::Fungible(FungibleAsset::new(eth_faucet.id(), 25)?); + + let mut rng = RpoRandomCoin::new(Word::default()); + // Create a PRIVATE swap note (output notes should also be Private) + let swap_note = PswapNote::create( + alice.id(), + offered_asset, + requested_asset, + NoteType::Private, + NoteAttachment::default(), + &mut rng, + )?; + builder.add_output_note(RawOutputNote::Full(swap_note.clone())); + + let mut mock_chain = builder.build()?; + + let mut note_args_map = BTreeMap::new(); + note_args_map.insert( + swap_note.id(), + Word::from([Felt::new(25), Felt::new(0), ZERO, ZERO]), + ); + + // Expected P2ID note should inherit Private type from swap note + let pswap = PswapNote::try_from(&swap_note)?; + let (p2id_note, _remainder) = pswap.execute(bob.id(), 25, 0)?; + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[swap_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 = Asset::Fungible(FungibleAsset::new(usdc_faucet.id(), 50)?); + let requested_asset = Asset::Fungible(FungibleAsset::new(eth_faucet.id(), 25)?); + + let mut rng = RpoRandomCoin::new(Word::default()); + let swap_note = PswapNote::create( + alice.id(), + offered_asset, + requested_asset, + NoteType::Public, + NoteAttachment::default(), + &mut rng, + )?; + builder.add_output_note(RawOutputNote::Full(swap_note.clone())); + + let mut mock_chain = builder.build()?; + + let mut note_args_map = BTreeMap::new(); + note_args_map.insert( + swap_note.id(), + Word::from([Felt::new(20), Felt::new(0), ZERO, ZERO]), + ); + + let pswap = PswapNote::try_from(&swap_note)?; + let (p2id_note, remainder_pswap) = pswap.execute(bob.id(), 20, 0)?; + let remainder_note = Note::from(remainder_pswap.expect("partial fill should produce remainder")); + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[swap_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 = RpoRandomCoin::new(Word::default()); + + // Alice's note: offers 50 USDC, requests 25 ETH + let alice_swap_note = PswapNote::create( + alice.id(), + Asset::Fungible(FungibleAsset::new(usdc_faucet.id(), 50)?), + Asset::Fungible(FungibleAsset::new(eth_faucet.id(), 25)?), + NoteType::Public, + NoteAttachment::default(), + &mut rng, + )?; + builder.add_output_note(RawOutputNote::Full(alice_swap_note.clone())); + + // Bob's note: offers 25 ETH, requests 50 USDC + let bob_swap_note = PswapNote::create( + bob.id(), + Asset::Fungible(FungibleAsset::new(eth_faucet.id(), 25)?), + Asset::Fungible(FungibleAsset::new(usdc_faucet.id(), 50)?), + NoteType::Public, + NoteAttachment::default(), + &mut rng, + )?; + builder.add_output_note(RawOutputNote::Full(bob_swap_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_swap_note.id(), + Word::from([Felt::new(0), Felt::new(25), ZERO, ZERO]), + ); + note_args_map.insert( + bob_swap_note.id(), + Word::from([Felt::new(0), Felt::new(50), ZERO, ZERO]), + ); + + // Expected P2ID notes + let alice_pswap = PswapNote::try_from(&alice_swap_note)?; + let (alice_p2id_note, _) = alice_pswap.execute(charlie.id(), 0, 25)?; + + let bob_pswap = PswapNote::try_from(&bob_swap_note)?; + let (bob_p2id_note, _) = bob_pswap.execute(charlie.id(), 0, 50)?; + + let tx_context = mock_chain + .build_tx_context(charlie.id(), &[alice_swap_note.id(), bob_swap_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 = RpoRandomCoin::new(Word::default()); + let swap_note = PswapNote::create( + alice.id(), + Asset::Fungible(FungibleAsset::new(usdc_faucet.id(), 50)?), + Asset::Fungible(FungibleAsset::new(eth_faucet.id(), 25)?), + NoteType::Public, + NoteAttachment::default(), + &mut rng, + )?; + builder.add_output_note(RawOutputNote::Full(swap_note.clone())); + + let mock_chain = builder.build()?; + + let tx_context = mock_chain.build_tx_context(alice.id(), &[swap_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 = RpoRandomCoin::new(Word::default()); + let swap_note = PswapNote::create( + alice.id(), + Asset::Fungible(FungibleAsset::new(usdc_faucet.id(), 50)?), + Asset::Fungible(FungibleAsset::new(eth_faucet.id(), 25)?), + NoteType::Public, + NoteAttachment::default(), + &mut rng, + )?; + builder.add_output_note(RawOutputNote::Full(swap_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( + swap_note.id(), + Word::from([Felt::new(30), Felt::new(0), ZERO, ZERO]), + ); + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[swap_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 = RpoRandomCoin::new(Word::default()); + let swap_note = PswapNote::create( + alice.id(), + Asset::Fungible(FungibleAsset::new(usdc_faucet.id(), 50)?), + Asset::Fungible(FungibleAsset::new(eth_faucet.id(), 25)?), + NoteType::Public, + NoteAttachment::default(), + &mut rng, + )?; + builder.add_output_note(RawOutputNote::Full(swap_note.clone())); + + let mock_chain = builder.build()?; + + let offered_out = PswapNote::calculate_output_amount(50, 25, input_amount); + + let mut note_args_map = BTreeMap::new(); + note_args_map.insert( + swap_note.id(), + Word::from([Felt::new(input_amount), Felt::new(0), ZERO, ZERO]), + ); + + let pswap = PswapNote::try_from(&swap_note)?; + let (p2id_note, remainder_pswap) = pswap.execute(bob.id(), input_amount, 0)?; + + 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(), &[swap_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 expected_output = + PswapNote::calculate_output_amount(offered_total, requested_total, input_amount); + + 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 = RpoRandomCoin::new(Word::default()); + let swap_note = PswapNote::create( + alice.id(), + Asset::Fungible(FungibleAsset::new(usdc_faucet.id(), offered_total)?), + Asset::Fungible(FungibleAsset::new(eth_faucet.id(), requested_total)?), + NoteType::Public, + NoteAttachment::default(), + &mut rng, + )?; + builder.add_output_note(RawOutputNote::Full(swap_note.clone())); + + let mock_chain = builder.build()?; + + let mut note_args_map = BTreeMap::new(); + note_args_map.insert( + swap_note.id(), + Word::from([Felt::new(input_amount), Felt::new(0), ZERO, ZERO]), + ); + + let pswap = PswapNote::try_from(&swap_note)?; + let (p2id_note, remainder_pswap) = pswap.execute(bob.id(), input_amount, 0)?; + let remainder = Note::from(remainder_pswap.expect("partial fill should produce remainder")); + + let tx_context = mock_chain + .build_tx_context(bob.id(), &[swap_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 offered_out = + PswapNote::calculate_output_amount(*offered_usdc, *requested_eth, *fill_eth); + let remaining_offered = offered_usdc - offered_out; + let remaining_requested = requested_eth - fill_eth; + + assert!(offered_out > 0, "Case {}: offered_out must be > 0", i + 1); + assert!(offered_out <= *offered_usdc, "Case {}: offered_out > offered", i + 1); + + 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 = RpoRandomCoin::new(Word::default()); + let swap_note = PswapNote::create( + alice.id(), + Asset::Fungible(FungibleAsset::new(usdc_faucet.id(), *offered_usdc)?), + Asset::Fungible(FungibleAsset::new(eth_faucet.id(), *requested_eth)?), + NoteType::Public, + NoteAttachment::default(), + &mut rng, + )?; + builder.add_output_note(RawOutputNote::Full(swap_note.clone())); + + let mock_chain = builder.build()?; + + let mut note_args_map = BTreeMap::new(); + note_args_map.insert( + swap_note.id(), + Word::from([Felt::new(*fill_eth), Felt::new(0), ZERO, ZERO]), + ); + + let pswap = PswapNote::try_from(&swap_note)?; + let (p2id_note, remainder_pswap) = pswap.execute(bob.id(), *fill_eth, 0)?; + + 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(), &[swap_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; + let mut current_swap_count = 0u64; + + // Track serial for remainder chain + let mut rng = RpoRandomCoin::new(Word::default()); + let mut current_serial = rng.draw_word(); + + for (fill_idx, fill_amount) in fills.iter().enumerate() { + let offered_out = + PswapNote::calculate_output_amount(current_offered, current_requested, *fill_amount); + let remaining_offered = current_offered - offered_out; + 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_asset = + Asset::Fungible(FungibleAsset::new(usdc_faucet.id(), current_offered)?); + let requested_asset = + Asset::Fungible(FungibleAsset::new(eth_faucet.id(), current_requested)?); + + let pswap_tag = PswapNote::build_tag(NoteType::Public, &offered_asset, &requested_asset); + let p2id_tag = NoteTag::with_account_target(alice.id()); + + let storage = PswapNoteStorage::from_parts( + requested_asset.to_key_word(), + requested_asset.to_value_word(), + pswap_tag, + p2id_tag, + current_swap_count, + alice.id(), + ); + 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 swap_note = Note::new(note_assets, metadata, recipient); + + builder.add_output_note(RawOutputNote::Full(swap_note.clone())); + let mock_chain = builder.build()?; + + let mut note_args_map = BTreeMap::new(); + note_args_map.insert( + swap_note.id(), + Word::from([Felt::new(*fill_amount), Felt::new(0), ZERO, ZERO]), + ); + + let pswap = PswapNote::try_from(&swap_note)?; + let (p2id_note, remainder_pswap) = pswap.execute(bob.id(), *fill_amount, 0)?; + + 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(), &[swap_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, + fill_idx + 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, + fill_idx + 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, fill_idx + 1); + if let Asset::Fungible(f) = &added[0] { + assert_eq!( + f.amount(), + offered_out, + "Chain {} fill {}: Bob should get {} USDC", + chain_idx + 1, + fill_idx + 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; + current_swap_count += 1; + // Remainder serial: [0] + 1 (matching MASM LE orientation) + current_serial = Word::from([ + Felt::new(current_serial[0].as_canonical_u64() + 1), + 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::create + 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::create + let mut rng = RpoRandomCoin::new(Word::default()); + let swap_note = PswapNote::create( + alice.id(), + Asset::Fungible(FungibleAsset::new(usdc_faucet.id(), 50).unwrap()), + Asset::Fungible(FungibleAsset::new(eth_faucet.id(), 25).unwrap()), + NoteType::Public, + NoteAttachment::default(), + &mut rng, + ) + .unwrap(); + + // Roundtrip: try_from -> execute -> verify outputs + let pswap = PswapNote::try_from(&swap_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.assets().num_assets(), 1, "Assets count mismatch after roundtrip"); + assert_eq!(pswap.storage().requested_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(), 25, 0).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(), 10, 0).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_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 = RpoRandomCoin::new(Word::default()); + let swap_note = PswapNote::create( + alice.id(), + Asset::Fungible(FungibleAsset::new(usdc_faucet.id(), 50).unwrap()), + Asset::Fungible(FungibleAsset::new(eth_faucet.id(), 25).unwrap()), + NoteType::Public, + NoteAttachment::default(), + &mut rng, + ) + .unwrap(); + + let storage = swap_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_amount(), 25, "Requested amount should be 25"); +}