diff --git a/crates/miden-standards/asm/standards/notes/swap.masm b/crates/miden-standards/asm/standards/notes/swap.masm index cfd11c1138..2347e8fd96 100644 --- a/crates/miden-standards/asm/standards/notes/swap.masm +++ b/crates/miden-standards/asm/standards/notes/swap.masm @@ -1,28 +1,58 @@ use miden::protocol::active_note use miden::protocol::asset use miden::protocol::output_note +use miden::protocol::note::NOTE_TYPE_PRIVATE +use miden::standards::notes::p2id use miden::standards::wallets::basic->wallet # CONSTANTS # ================================================================================================= -const SWAP_NOTE_NUM_STORAGE_ITEMS=14 - +const SWAP_NOTE_NUM_STORAGE_ITEMS=16 + +# Note storage layout (16 felts, loaded at STORAGE_PTR by get_storage): +# - requested_asset_key [0..3] +# - requested_asset_value [4..7] +# - payback_recipient [8..11] (private mode only; zero in public mode) +# - payback_note_type [12] +# - payback_tag [13] +# - payback_target_prefix [14] (public mode only; zero in private mode) +# - payback_target_suffix [15] (public mode only; zero in private mode) +# +# In private mode, the recipient digest and tag are precomputed off-chain by the creator and +# embedded as opaque values; the consumer of the SWAP cannot learn who the payback targets from +# storage alone. In public mode, the recipient must be reconstructible by any consumer, so the +# payback target account id is embedded in plaintext and the MASM derives the recipient at +# consume time. The payback tag is stored explicitly in both modes; the creator is responsible +# for picking a tag that targets the payback receiver in public mode. +# +# The payback target id could be derived from `active_note::get_sender` since today the target +# equals the SWAP sender, but it's stored explicitly because this slot is meant to represent an +# arbitrary payback target (not necessarily the creator) in a future iteration. +const STORAGE_PTR=0 const REQUESTED_ASSET_PTR=0 const PAYBACK_RECIPIENT_PTR=8 const PAYBACK_NOTE_TYPE_PTR=12 -const PAYBACK_NOTE_TAG_PTR=13 +const PAYBACK_TAG_PTR=13 +const PAYBACK_TARGET_PREFIX_PTR=14 +const PAYBACK_TARGET_SUFFIX_PTR=15 + +# Storage is exactly 16 felts = 4 words, so the asset region starts right after. const ASSET_PTR=16 -# ERRORS +# ERRORS # ================================================================================================= -const ERR_SWAP_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS="SWAP script expects exactly 14 note storage items" +const ERR_SWAP_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS="SWAP script expects exactly 16 note storage items" const ERR_SWAP_WRONG_NUMBER_OF_ASSETS="SWAP script requires exactly 1 note asset" -#! Swap script: adds an asset from the note into consumers account and -#! creates a note consumable by note issuer containing requested asset. +#! Swap script: adds the offered asset from the note into the consumer's account and creates a +#! P2ID payback note addressed to the creator carrying the requested asset. +#! +#! The payback note type (selected by the creator) determines how the payback recipient is +#! derived: private uses an opaque precomputed digest, public derives it from the payback target +#! account id stored in plaintext. #! #! Requires that the account exposes: #! - miden::standards::wallets::basic::receive_asset procedure. @@ -31,47 +61,68 @@ const ERR_SWAP_WRONG_NUMBER_OF_ASSETS="SWAP script requires exactly 1 note asset #! Inputs: [ARGS] #! Outputs: [] #! -#! Note storage is assumed to be as follows: -#! - REQUESTED_ASSET_KEY -#! - REQUESTED_ASSET_VALUE -#! - PAYBACK_RECIPIENT -#! - payback_note_type -#! - payback_note_tag -#! #! Panics if: -#! - account does not expose miden::standards::wallets::basic::receive_asset procedure. -#! - account does not expose miden::standards::wallets::basic::move_asset_to_note procedure. +#! - account does not expose the required wallet procedures. #! - account vault does not contain the requested asset. #! - adding a fungible asset would result in amount overflow, i.e., the total amount would be #! greater than 2^63. @note_script pub proc main - # dropping note args + # drop note args dropw # => [] - # --- create a payback note with the requested asset ---------------- - - # store note storage into memory starting at address 0 - push.0 exec.active_note::get_storage + # store note storage into memory starting at STORAGE_PTR + push.STORAGE_PTR exec.active_note::get_storage # => [num_storage_items] # check number of storage items eq.SWAP_NOTE_NUM_STORAGE_ITEMS assert.err=ERR_SWAP_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS # => [] - padw mem_loadw_le.PAYBACK_RECIPIENT_PTR - # => [PAYBACK_NOTE_RECIPIENT] + # --- create payback P2ID note - # load payback P2ID details + # Branch on payback note type. mem_load.PAYBACK_NOTE_TYPE_PTR - mem_load.PAYBACK_NOTE_TAG_PTR - # => [tag, note_type, PAYBACK_NOTE_RECIPIENT] - - # create payback P2ID note - exec.output_note::create + eq.NOTE_TYPE_PRIVATE + # => [is_private] + + if.true + # --- PRIVATE PAYBACK --- + # Load the precomputed payback recipient (4 felts, word-aligned). + padw mem_loadw_le.PAYBACK_RECIPIENT_PTR + # => [PAYBACK_RECIPIENT] + + mem_load.PAYBACK_NOTE_TYPE_PTR + mem_load.PAYBACK_TAG_PTR + # => [tag, note_type, PAYBACK_RECIPIENT] + + exec.output_note::create + # => [note_idx] + else + # --- PUBLIC PAYBACK --- + # Derive P2ID serial = SWAP_SERIAL with the least significant element +1. + exec.active_note::get_serial_number + add.1 + # => [s0', s1, s2, s3] + + mem_load.PAYBACK_NOTE_TYPE_PTR + mem_load.PAYBACK_TAG_PTR + # => [tag, note_type, s0', s1, s2, s3] + + # Load payback target id (prefix then suffix) to match `p2id::new`'s signature + # [target_id_suffix, target_id_prefix, tag, note_type, SERIAL_NUM]. + mem_load.PAYBACK_TARGET_PREFIX_PTR + mem_load.PAYBACK_TARGET_SUFFIX_PTR + # => [target_suffix, target_prefix, tag, note_type, s0', s1, s2, s3] + + exec.p2id::new + # => [note_idx] + end # => [note_idx] + # --- move requested asset into the payback P2ID note + padw push.0.0.0 movup.7 # => [note_idx, pad(7)] @@ -85,7 +136,7 @@ pub proc main dropw dropw # => [pad(8)] - # --- move assets from the SWAP note into the account ------------------------- + # --- move offered asset from the SWAP note into the consumer's account # store the number of note assets to memory starting at address ASSET_PTR push.ASSET_PTR exec.active_note::get_assets diff --git a/crates/miden-standards/src/note/mod.rs b/crates/miden-standards/src/note/mod.rs index f16af90759..a0c58404ea 100644 --- a/crates/miden-standards/src/note/mod.rs +++ b/crates/miden-standards/src/note/mod.rs @@ -25,7 +25,7 @@ mod pswap; pub use pswap::{PswapNote, PswapNoteStorage}; mod swap; -pub use swap::{SwapNote, SwapNoteStorage}; +pub use swap::{SwapNote, SwapNoteStorage, SwapPayback, payback_serial_from_swap}; mod network_account_target; pub use network_account_target::{NetworkAccountTarget, NetworkAccountTargetError}; diff --git a/crates/miden-standards/src/note/swap.rs b/crates/miden-standards/src/note/swap.rs index 17a7347af6..0ad249acd7 100644 --- a/crates/miden-standards/src/note/swap.rs +++ b/crates/miden-standards/src/note/swap.rs @@ -1,6 +1,5 @@ use alloc::vec::Vec; -use miden_protocol::Word; use miden_protocol::account::AccountId; use miden_protocol::assembly::Path; use miden_protocol::asset::Asset; @@ -20,6 +19,7 @@ use miden_protocol::note::{ PartialNoteMetadata, }; use miden_protocol::utils::sync::LazyLock; +use miden_protocol::{Felt, ONE, Word}; use crate::StandardsLib; use crate::note::P2idNoteStorage; @@ -74,6 +74,8 @@ impl SwapNote { /// that is willing to consume the note. The consumer will receive the `offered_asset` and /// will create a new P2ID note with `sender` as target, containing the `requested_asset`. /// + /// See [`SwapPayback`] for how the two payback modes shape the SWAP note storage. + /// /// # Errors /// Returns an error if deserialization or compilation of the `SWAP` script fails. pub fn create( @@ -89,12 +91,24 @@ impl SwapNote { return Err(NoteError::other("requested asset same as offered asset")); } - let payback_serial_num = rng.draw_word(); + let serial_num = rng.draw_word(); - let swap_storage = - SwapNoteStorage::new(sender, requested_asset, payback_note_type, payback_serial_num); + // The payback recipient is P2ID(sender) with serial = swap_serial + 1, in both modes. + let payback_serial_num = payback_serial_from_swap(serial_num); + let payback_recipient = P2idNoteStorage::new(sender).into_recipient(payback_serial_num); + let payback_assets = NoteAssets::new(vec![requested_asset])?; + let payback_note = NoteDetails::new(payback_assets, payback_recipient.clone()); + + let payback_tag = NoteTag::with_account_target(sender); + let swap_storage = match payback_note_type { + NoteType::Private => SwapNoteStorage::new_private( + requested_asset, + payback_recipient.digest(), + payback_tag, + ), + NoteType::Public => SwapNoteStorage::new_public(requested_asset, sender, payback_tag), + }; - let serial_num = rng.draw_word(); let recipient = swap_storage.into_recipient(serial_num); // build the tag for the SWAP use case @@ -105,11 +119,6 @@ impl SwapNote { let assets = NoteAssets::new(vec![offered_asset])?; let note = Note::with_attachments(assets, metadata, recipient, swap_note_attachments); - // build the payback note details - let payback_recipient = P2idNoteStorage::new(sender).into_recipient(payback_serial_num); - let payback_assets = NoteAssets::new(vec![requested_asset])?; - let payback_note = NoteDetails::new(payback_assets, payback_recipient); - Ok((note, payback_note)) } @@ -158,15 +167,43 @@ impl SwapNote { /// Canonical storage representation for a SWAP note. /// -/// Contains the payback note configuration and the requested asset that the -/// swap creator wants to receive in exchange for the offered asset contained -/// in the note's vault. +/// Maps to the 16-element [`NoteStorage`] layout consumed by the on-chain MASM script: +/// +/// | Slot | Field | +/// |-----------|-------| +/// | `[0..7]` | Requested asset (key + value) | +/// | `[8..11]` | Payback recipient digest (private mode; zero in public mode) | +/// | `[12]` | Payback note type | +/// | `[13]` | Payback note tag | +/// | `[14]` | Payback target account ID prefix (public mode; zero in private mode) | +/// | `[15]` | Payback target account ID suffix (public mode; zero in private mode) | +/// +/// See [`SwapPayback`] for the rationale behind the per-mode shape. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SwapNoteStorage { - payback_note_type: NoteType, - payback_tag: NoteTag, requested_asset: Asset, - payback_recipient_digest: Word, + payback_tag: NoteTag, + payback: SwapPayback, +} + +/// Mode-specific payback data embedded in [`SwapNoteStorage`]. +/// +/// The variant determines how the payback recipient is materialized at consume time: +/// - [`SwapPayback::Private`] embeds the precomputed P2ID recipient digest as an opaque value, so +/// the SWAP storage alone does not reveal who the payback targets. +/// - [`SwapPayback::Public`] embeds the payback target account id in plaintext, so any consumer can +/// reconstruct the payback recipient at consume time via `p2id::new`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SwapPayback { + Private { + /// Precomputed P2ID recipient digest for the payback note. + recipient: Word, + }, + Public { + /// Account ID of the payback receiver. Today this is the SWAP creator by convention, but + /// the script does not enforce it: it can be any account in a future iteration. + payback_target_id: AccountId, + }, } impl SwapNoteStorage { @@ -174,52 +211,46 @@ impl SwapNoteStorage { // -------------------------------------------------------------------------------------------- /// Expected number of storage items of the SWAP note. - pub const NUM_ITEMS: usize = 14; + pub const NUM_ITEMS: usize = 16; // CONSTRUCTORS // -------------------------------------------------------------------------------------------- - /// Creates new SWAP note storage with the specified parameters. - pub fn new( - sender: AccountId, + /// Creates a new SWAP note storage for a private payback. + pub fn new_private( requested_asset: Asset, - payback_note_type: NoteType, - payback_serial_number: Word, + payback_recipient: Word, + payback_tag: NoteTag, ) -> Self { - let payback_recipient = P2idNoteStorage::new(sender).into_recipient(payback_serial_number); - let payback_tag = NoteTag::with_account_target(sender); - - Self::from_parts( - payback_note_type, - payback_tag, + Self { requested_asset, - payback_recipient.digest(), - ) + payback_tag, + payback: SwapPayback::Private { recipient: payback_recipient }, + } } - /// Creates a [`SwapNoteStorage`] from raw parts. - pub fn from_parts( - payback_note_type: NoteType, - payback_tag: NoteTag, + /// Creates a new SWAP note storage for a public payback. + pub fn new_public( requested_asset: Asset, - payback_recipient_digest: Word, + payback_target_id: AccountId, + payback_tag: NoteTag, ) -> Self { Self { - payback_note_type, - payback_tag, requested_asset, - payback_recipient_digest, + payback_tag, + payback: SwapPayback::Public { payback_target_id }, } } - /// Returns the payback note type. - pub fn payback_note_type(&self) -> NoteType { - self.payback_note_type - } + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- - /// Returns the payback note tag. - pub fn payback_tag(&self) -> NoteTag { - self.payback_tag + /// Returns the payback note type implied by the payback variant. + pub fn payback_note_type(&self) -> NoteType { + match self.payback { + SwapPayback::Private { .. } => NoteType::Private, + SwapPayback::Public { .. } => NoteType::Public, + } } /// Returns the requested asset. @@ -227,9 +258,14 @@ impl SwapNoteStorage { self.requested_asset } - /// Returns the payback recipient digest. - pub fn payback_recipient_digest(&self) -> Word { - self.payback_recipient_digest + /// Returns the tag attached to the payback note. + pub fn payback_tag(&self) -> NoteTag { + self.payback_tag + } + + /// Returns the payback variant of this storage. + pub fn payback(&self) -> &SwapPayback { + &self.payback } /// Consumes the storage and returns a SWAP [`NoteRecipient`] with the provided serial number. @@ -244,16 +280,124 @@ impl SwapNoteStorage { impl From for NoteStorage { fn from(storage: SwapNoteStorage) -> Self { let mut storage_values = Vec::with_capacity(SwapNoteStorage::NUM_ITEMS); + + // [0..7] requested asset storage_values.extend_from_slice(&storage.requested_asset.as_elements()); - storage_values.extend_from_slice(storage.payback_recipient_digest.as_elements()); - storage_values - .extend_from_slice(&[storage.payback_note_type.into(), storage.payback_tag.into()]); + + match storage.payback { + SwapPayback::Private { recipient } => { + // [8..11] payback recipient digest + storage_values.extend_from_slice(recipient.as_elements()); + // [12] payback note type + storage_values.push(Felt::from(NoteType::Private.as_u8())); + // [13] payback tag + storage_values.push(Felt::from(storage.payback_tag.as_u32())); + // [14..15] payback target id (zero in private mode) + storage_values.extend_from_slice(&[Felt::ZERO; 2]); + }, + SwapPayback::Public { payback_target_id } => { + // [8..11] payback recipient (zero in public mode) + storage_values.extend_from_slice(&[Felt::ZERO; 4]); + // [12] payback note type + storage_values.push(Felt::from(NoteType::Public.as_u8())); + // [13] payback tag + storage_values.push(Felt::from(storage.payback_tag.as_u32())); + // [14..15] payback target id (prefix, suffix) + storage_values.push(payback_target_id.prefix().as_felt()); + storage_values.push(payback_target_id.suffix()); + }, + } NoteStorage::new(storage_values) .expect("number of storage items should not exceed max storage items") } } +/// Deserializes [`SwapNoteStorage`] from a slice of exactly 16 [`Felt`]s. +impl TryFrom<&[Felt]> for SwapNoteStorage { + type Error = NoteError; + + fn try_from(note_storage: &[Felt]) -> Result { + if note_storage.len() != Self::NUM_ITEMS { + return Err(NoteError::InvalidNoteStorageLength { + expected: Self::NUM_ITEMS, + actual: note_storage.len(), + }); + } + + // [0..7] = requested asset (key + value) + let key = Word::new([note_storage[0], note_storage[1], note_storage[2], note_storage[3]]); + let value = Word::new([note_storage[4], note_storage[5], note_storage[6], note_storage[7]]); + let requested_asset = Asset::from_key_value_words(key, value) + .map_err(|e| NoteError::other_with_source("failed to parse requested asset", e))?; + + // [12] = payback_note_type + let payback_note_type = NoteType::try_from( + u8::try_from(note_storage[12].as_canonical_u64()) + .map_err(|_| NoteError::other("payback_note_type exceeds u8"))?, + ) + .map_err(|e| NoteError::other_with_source("failed to parse payback note type", e))?; + + // [13] = payback tag + let payback_tag_u32 = u32::try_from(note_storage[13].as_canonical_u64()) + .map_err(|_| NoteError::other("SWAP payback_tag exceeds u32"))?; + let payback_tag = NoteTag::new(payback_tag_u32); + + let payback = match payback_note_type { + NoteType::Private => { + // [14..15] must be zero so a private SWAP cannot leak a payback target id. + if note_storage[14].as_canonical_u64() != 0 + || note_storage[15].as_canonical_u64() != 0 + { + return Err(NoteError::other( + "SWAP private payback must have payback target id slots cleared", + )); + } + + // [8..11] payback recipient digest + let recipient = Word::new([ + note_storage[8], + note_storage[9], + note_storage[10], + note_storage[11], + ]); + + SwapPayback::Private { recipient } + }, + NoteType::Public => { + // [8..11] must be zero so the storage shape is unambiguous. + if note_storage[8..=11].iter().any(|f| f.as_canonical_u64() != 0) { + return Err(NoteError::other( + "SWAP public payback must have recipient slots cleared", + )); + } + + let payback_target_id = AccountId::try_from_elements( + note_storage[15], + note_storage[14], + ) + .map_err(|e| { + NoteError::other_with_source("failed to parse payback target account ID", e) + })?; + + SwapPayback::Public { payback_target_id } + }, + }; + + Ok(Self { requested_asset, payback_tag, payback }) + } +} + +/// Returns the P2ID payback serial derived from a SWAP note's own serial number. +/// +/// The SWAP MASM script computes the payback's serial by incrementing the least significant +/// element of the SWAP serial. Creators can recompute this offline to track or consume the +/// payback note after the SWAP is filled. +pub fn payback_serial_from_swap(swap_serial: Word) -> Word { + let elements = swap_serial.as_elements(); + Word::new([elements[0] + ONE, elements[1], elements[2], elements[3]]) +} + // TESTS // ================================================================================================ @@ -262,7 +406,7 @@ mod tests { use miden_protocol::account::{AccountIdVersion, AccountType}; use miden_protocol::asset::{FungibleAsset, NonFungibleAsset, NonFungibleAssetDetails}; - use miden_protocol::note::{NoteStorage, NoteTag, NoteType}; + use miden_protocol::note::{NoteStorage, NoteType}; use miden_protocol::testing::account_id::{ ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET, @@ -287,49 +431,113 @@ mod tests { Asset::NonFungible(NonFungibleAsset::new(&details)) } + fn dummy_target_id() -> AccountId { + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Public) + } + + fn dummy_recipient_digest() -> Word { + Word::new([Felt::from(7u32), Felt::from(11u32), Felt::from(13u32), Felt::from(17u32)]) + } + + fn dummy_payback_tag() -> NoteTag { + NoteTag::new(0xabcd1234) + } + #[test] - fn swap_note_storage() { - let payback_note_type = NoteType::Private; - let payback_tag = NoteTag::new(0x12345678); - let requested_asset = fungible_asset(); - let payback_recipient_digest = Word::from([1_u32, 2_u32, 3_u32, 4_u32]); - - let storage = SwapNoteStorage::from_parts( - payback_note_type, - payback_tag, - requested_asset, - payback_recipient_digest, + fn swap_note_storage_round_trip_fungible_private() { + let storage = SwapNoteStorage::new_private( + fungible_asset(), + dummy_recipient_digest(), + dummy_payback_tag(), ); - assert_eq!(storage.payback_note_type(), payback_note_type); - assert_eq!(storage.payback_tag(), payback_tag); - assert_eq!(storage.requested_asset(), requested_asset); - assert_eq!(storage.payback_recipient_digest(), payback_recipient_digest); + let note_storage = NoteStorage::from(storage.clone()); + assert_eq!(note_storage.num_items() as usize, SwapNoteStorage::NUM_ITEMS); + assert_eq!(storage.payback_note_type(), NoteType::Private); + assert_eq!(storage.requested_asset(), fungible_asset()); + assert_eq!(storage.payback_tag(), dummy_payback_tag()); + match storage.payback() { + SwapPayback::Private { recipient } => { + assert_eq!(*recipient, dummy_recipient_digest()); + }, + SwapPayback::Public { .. } => panic!("expected private payback"), + } + } + + #[test] + fn swap_note_storage_round_trip_non_fungible_public() { + let target = dummy_target_id(); + let storage = + SwapNoteStorage::new_public(non_fungible_asset(), target, dummy_payback_tag()); - // Convert to NoteStorage - let note_storage = NoteStorage::from(storage); + let note_storage = NoteStorage::from(storage.clone()); assert_eq!(note_storage.num_items() as usize, SwapNoteStorage::NUM_ITEMS); + assert_eq!(storage.payback_note_type(), NoteType::Public); + assert_eq!(storage.requested_asset(), non_fungible_asset()); + assert_eq!(storage.payback_tag(), dummy_payback_tag()); + match storage.payback() { + SwapPayback::Public { payback_target_id } => { + assert_eq!(*payback_target_id, target); + }, + SwapPayback::Private { .. } => panic!("expected public payback"), + } } #[test] - fn swap_note_storage_with_non_fungible_asset() { - let payback_note_type = NoteType::Public; - let payback_tag = NoteTag::new(0xaabbccdd); - let requested_asset = non_fungible_asset(); - let payback_recipient_digest = Word::from([10_u32, 20_u32, 30_u32, 40_u32]); - - let storage = SwapNoteStorage::from_parts( - payback_note_type, - payback_tag, - requested_asset, - payback_recipient_digest, + fn swap_note_storage_try_from_round_trip_public() { + let original = + SwapNoteStorage::new_public(fungible_asset(), dummy_target_id(), dummy_payback_tag()); + let note_storage = NoteStorage::from(original.clone()); + + let parsed = + SwapNoteStorage::try_from(note_storage.items()).expect("round trip should succeed"); + + assert_eq!(parsed, original); + } + + #[test] + fn swap_note_storage_try_from_round_trip_private() { + let original = SwapNoteStorage::new_private( + fungible_asset(), + dummy_recipient_digest(), + dummy_payback_tag(), ); + let note_storage = NoteStorage::from(original.clone()); - assert_eq!(storage.payback_note_type(), payback_note_type); - assert_eq!(storage.requested_asset(), requested_asset); + let parsed = + SwapNoteStorage::try_from(note_storage.items()).expect("round trip should succeed"); - let note_storage = NoteStorage::from(storage); - assert_eq!(note_storage.num_items() as usize, SwapNoteStorage::NUM_ITEMS); + assert_eq!(parsed, original); + } + + #[test] + fn swap_note_storage_private_rejects_dirty_target_slots() { + let mut items: Vec = NoteStorage::from(SwapNoteStorage::new_private( + fungible_asset(), + dummy_recipient_digest(), + dummy_payback_tag(), + )) + .items() + .to_vec(); + + // Inject a non-zero target prefix in the slot that must stay clear for private payback. + items[14] = Felt::from(1u32); + assert!(SwapNoteStorage::try_from(items.as_slice()).is_err()); + } + + #[test] + fn swap_note_storage_public_rejects_dirty_private_slots() { + let mut items: Vec = NoteStorage::from(SwapNoteStorage::new_public( + fungible_asset(), + dummy_target_id(), + dummy_payback_tag(), + )) + .items() + .to_vec(); + + // Inject a non-zero recipient felt in the slot that must stay clear for public payback. + items[8] = Felt::from(1u32); + assert!(SwapNoteStorage::try_from(items.as_slice()).is_err()); } #[test] diff --git a/crates/miden-testing/tests/scripts/swap.rs b/crates/miden-testing/tests/scripts/swap.rs index 20c3d3fcfb..281ce7896b 100644 --- a/crates/miden-testing/tests/scripts/swap.rs +++ b/crates/miden-testing/tests/scripts/swap.rs @@ -161,6 +161,61 @@ async fn consume_swap_note_private_payback_note() -> anyhow::Result<()> { Ok(()) } +// Consumes a SWAP note with a public payback without any off-band advice. The executor materializes +// the payback recipient from the creator account ID embedded in SWAP storage and the SWAP's own +// serial number, then registers it with the advice provider via `p2id::new -> +// note::build_recipient`. +#[tokio::test] +async fn consume_swap_note_public_payback_note_no_advice() -> anyhow::Result<()> { + let payback_note_type = NoteType::Public; + let SwapTestSetup { + mock_chain, + mut sender_account, + mut target_account, + offered_asset, + requested_asset, + swap_note, + payback_note, + } = setup_swap_test(payback_note_type)?; + + let consume_swap_note_tx = mock_chain + .build_tx_context(target_account.id(), &[swap_note.id()], &[]) + .context("failed to build tx context")? + .build()? + .execute() + .await?; + + target_account.apply_delta(consume_swap_note_tx.account_delta())?; + + let output_payback_note = consume_swap_note_tx.output_notes().iter().next().unwrap().clone(); + assert_eq!( + output_payback_note.id(), + NoteId::new(payback_note.commitment(), output_payback_note.metadata()) + ); + assert_eq!(output_payback_note.assets().iter().next().unwrap(), &requested_asset); + + assert_eq!(target_account.vault().assets().count(), 1); + assert!(target_account.vault().assets().any(|asset| asset == offered_asset)); + + let full_payback_note = Note::new( + payback_note.assets().clone(), + *output_payback_note.metadata().partial_metadata(), + payback_note.recipient().clone(), + ); + + let consume_payback_tx = mock_chain + .build_tx_context(sender_account.id(), &[], &[full_payback_note]) + .context("failed to build tx context")? + .build()? + .execute() + .await?; + + sender_account.apply_delta(consume_payback_tx.account_delta())?; + assert!(sender_account.vault().assets().any(|asset| asset == requested_asset)); + + Ok(()) +} + // Creates a swap note with a public payback note, then consumes it to complete the swap // The target account receives the offered asset and creates a public payback note for the sender #[tokio::test] diff --git a/docs/src/note.md b/docs/src/note.md index c0a4b7cda1..e40da3adb9 100644 --- a/docs/src/note.md +++ b/docs/src/note.md @@ -226,13 +226,16 @@ The SWAP note script implements atomic asset swapping functionality. **Key characteristics:** - **Purpose:** Atomic asset exchange between two parties -- **Storage:** Requires exactly 16 storage items specifying: - - Requested asset details - - Payback note recipient information - - Note creation parameters (type, tag, attachment) +- **Storage:** Requires exactly 16 storage items, laid out as: + - Requested asset (8 felts) + - Payback recipient digest (4 felts; used for private payback, zero for public) + - Payback note type + - Payback tag + - Payback target account ID prefix (used for public payback, zero for private) + - Payback target account ID suffix (used for public payback, zero for private) - **Assets:** Must contain exactly 1 asset to be swapped - **Mechanism:** - 1. Creates a payback note containing the requested asset for the original note issuer + 1. Creates a payback P2ID note containing the requested asset for the original note issuer. For private payback, the precomputed recipient digest is loaded from storage and used directly. For public payback, the recipient is reconstructed on-chain from the payback target account ID and a serial derived as `swap_serial + 1`, which also registers the preimage in the advice map so the public note can be validated. 2. Adds the note's asset to the consuming account's vault - **Requirements:** Account must expose both: - `miden::standards::wallets::basic::receive_asset` procedure