From dd1d47f37fc0374fc5a1635861ea942a36275a18 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 20 Aug 2026 15:43:17 +0000 Subject: [PATCH 1/7] fix(standards): price PSWAP fills against the note's initial offered asset The PSWAP note script priced a fill against the offered asset *remaining* in the note at consumption time rather than the amount the creator funded. A consumer whose own account exposes an indexed-removal procedure could, via an earlier helper note in the same transaction, drain most of the offered asset out of the PSWAP note into their own vault before the PSWAP script ran; the single-asset assert still passed on the partially-removed slot, so the fill was priced against the residue and the creator's remainder note absorbed the loss. Bind the offered amount to the note's initial assets, which the prologue records at note creation and which removals never affect. `load_offered_asset` now fetches the note's initial assets, asserts exactly one, and asserts the single remaining offered asset equals the single initial one word-for-word (asset ID and value), aborting with the new ERR_PSWAP_OFFERED_ASSET_ALTERED otherwise. Any pre-removal from a PSWAP note thus aborts the whole transaction. Both the fill (execute_pswap) and reclaim (handle_reclaim) paths go through this procedure. This changes the compiled PSWAP note-script MAST root and therefore PSWAP note recipients/IDs. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + .../asm/standards/notes/pswap.masm | 43 ++++- crates/miden-standards/src/note/pswap.rs | 6 + crates/miden-testing/tests/scripts/pswap.rs | 161 +++++++++++++++++- 4 files changed, 204 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58870cf879..de78e99f99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ - Fixed `input_note::remove_asset` succeeding when asked to remove an empty or malformed asset ID instead of reporting the asset as not found ([#3592](https://github.com/0xMiden/protocol/pull/3607)). - Faucet asset-callback procedure roots are now verified against the faucet's account code before dispatch, so a misconfigured callback root can no longer make an asset nontransferable ([#3612](https://github.com/0xMiden/protocol/pull/3612)). - [BREAKING] Enforced the limit of 1024 per asset delta op for added and removed account vault deltas inside and outside the tx kernel ([#3623](https://github.com/0xMiden/protocol/pull/3623)). +- [BREAKING] Priced PSWAP fills against the note's initial offered asset rather than its remaining assets, so a pre-consumption indexed asset removal can no longer drain the offered side; the PSWAP note script root and PSWAP note IDs change ([#3601](https://github.com/0xMiden/protocol/issues/3601)). ## v0.16.0 (2026-08-17) diff --git a/crates/miden-standards/asm/standards/notes/pswap.masm b/crates/miden-standards/asm/standards/notes/pswap.masm index 4db17dc4c6..ffcf14eede 100644 --- a/crates/miden-standards/asm/standards/notes/pswap.masm +++ b/crates/miden-standards/asm/standards/notes/pswap.masm @@ -100,6 +100,7 @@ const PARENT_ATTACHMENT_DEPTH_OFFSET = 2 const ERR_PSWAP_WRONG_NUMBER_OF_STORAGE_ITEMS="PSWAP script expects exactly 7 note storage items" const ERR_PSWAP_WRONG_NUMBER_OF_ASSETS="PSWAP script requires exactly one note asset" +const ERR_PSWAP_OFFERED_ASSET_ALTERED="PSWAP offered asset differs from the asset the note was created with" const ERR_PSWAP_FILL_SUM_OVERFLOW="PSWAP account_fill + note_fill overflows u64" const ERR_PSWAP_NOT_VALID_ASSET_AMOUNT="PSWAP computed amount exceeds max fungible asset amount" const ERR_PSWAP_PAYOUT_OVERFLOW="PSWAP payout quotient does not fit in u64" @@ -449,23 +450,53 @@ proc is_consumer_creator # => [is_creator] end -#! Loads the offered asset from the active note and validates there is exactly one asset. +#! Loads the offered asset from the active note, bound to the note's initial assets so a fill is +#! always priced against the amount the creator funded rather than whatever remains at consumption. +#! +#! Asset removals (e.g. an earlier native-account indexed removal via `input_note::remove_asset`) +#! only shrink the assets remaining in the note; they never affect its initial assets. This +#! procedure therefore asserts that the single remaining offered asset still equals, word-for-word, +#! the single asset the note was created with. Any prior removal from the PSWAP note aborts the whole +#! transaction rather than letting the payout and remainder be sized from the residue. #! #! Inputs: [] #! Outputs: [ASSET_ID, ASSET_VALUE] #! -@locals(8) +#! Panics if: +#! - the note does not carry exactly one remaining asset. +#! - the note was not created with exactly one asset. +#! - the remaining offered asset differs from the asset the note was created with. +#! +@locals(16) proc load_offered_asset + # Remove the note's remaining assets and require exactly one. locaddr.0 exec.active_note::remove_all_assets # => [num_assets] push.1 eq assert.err=ERR_PSWAP_WRONG_NUMBER_OF_ASSETS # => [] - locaddr.0 - # => [dest_ptr] + # Fetch the note's initial assets (unaffected by removals) and require exactly one. + locaddr.8 exec.active_note::get_initial_assets + # => [num_initial_assets] + + push.1 eq assert.err=ERR_PSWAP_OFFERED_ASSET_ALTERED + # => [] + + # Bind the offered asset to what the note was funded with: the remaining offered asset must match + # the initial offered asset word-for-word (asset ID and value). + locaddr.0 exec.asset::load + # => [ASSET_ID, ASSET_VALUE] + + locaddr.8 exec.asset::load + # => [INITIAL_ASSET_ID, INITIAL_ASSET_VALUE, ASSET_ID, ASSET_VALUE] + + movupw.2 assert_eqw.err=ERR_PSWAP_OFFERED_ASSET_ALTERED + assert_eqw.err=ERR_PSWAP_OFFERED_ASSET_ALTERED + # => [] - exec.asset::load + # Re-load the offered asset (still in memory) as the return value. + locaddr.0 exec.asset::load # => [ASSET_ID, ASSET_VALUE] end @@ -808,6 +839,8 @@ end #! Panics if: #! - the number of note storage items is not `NUM_STORAGE_ITEMS`. #! - the note does not hold exactly one offered asset at consumption time. +#! - the offered asset remaining in the note differs from the asset the note was created with (i.e. +#! it was partially removed before the script ran). #! - the total fill (account_fill + note_fill) overflows u64 or exceeds the max asset amount. #! - the account does not expose `receive_asset` / `move_asset_to_note`. @note_script diff --git a/crates/miden-standards/src/note/pswap.rs b/crates/miden-standards/src/note/pswap.rs index ac360be6c9..1ba7f4817e 100644 --- a/crates/miden-standards/src/note/pswap.rs +++ b/crates/miden-standards/src/note/pswap.rs @@ -267,6 +267,12 @@ impl From for NoteAttachment { /// `[0, 0, 0, 0]`, triggering a full fill). To route a PSWAP note to a network account, /// set the `attachment` to a [`NetworkAccountTarget`](crate::note::NetworkAccountTarget) /// via the builder. +/// +/// Fills are priced against the note's initial offered asset - the amount the creator funded, as +/// recorded at note creation - rather than whatever remains at consumption time. The on-chain +/// script asserts that the two are equal, so any pre-consumption asset removal (e.g. an earlier +/// native-account indexed removal) aborts the transaction instead of letting a fill be priced +/// against the residue. #[derive(Debug, Clone, bon::Builder)] #[builder(finish_fn(vis = "", name = build_internal))] pub struct PswapNote { diff --git a/crates/miden-testing/tests/scripts/pswap.rs b/crates/miden-testing/tests/scripts/pswap.rs index f158f2d81a..e7cb08ab8b 100644 --- a/crates/miden-testing/tests/scripts/pswap.rs +++ b/crates/miden-testing/tests/scripts/pswap.rs @@ -1,7 +1,14 @@ use std::collections::BTreeMap; use miden_protocol::account::auth::AuthScheme; -use miden_protocol::account::{Account, AccountId, AccountType, AccountVaultPatch}; +use miden_protocol::account::component::AccountComponentMetadata; +use miden_protocol::account::{ + Account, + AccountComponent, + AccountId, + AccountType, + AccountVaultPatch, +}; use miden_protocol::asset::{Asset, AssetAmount, AssetId, FungibleAsset}; use miden_protocol::crypto::rand::{FeltRng, RandomCoin}; use miden_protocol::errors::MasmError; @@ -10,14 +17,22 @@ use miden_protocol::testing::account_id::AccountIdBuilder; use miden_protocol::transaction::{RawOutputNote, RawOutputNotes}; use miden_protocol::{Felt, ONE, Word, ZERO}; use miden_standards::account::wallets::BasicWallet; +use miden_standards::code_builder::CodeBuilder; use miden_standards::errors::standards::{ ERR_PSWAP_FILL_BELOW_MINIMUM, ERR_PSWAP_FILL_SUM_OVERFLOW, ERR_PSWAP_NOT_VALID_ASSET_AMOUNT, + ERR_PSWAP_OFFERED_ASSET_ALTERED, }; use miden_standards::note::{PswapNote, PswapNoteAttachment, PswapNoteStorage}; use miden_standards::testing::note::NoteBuilder; -use miden_testing::{Auth, MockChain, MockChainBuilder, assert_transaction_executor_error}; +use miden_testing::{ + AccountState, + Auth, + MockChain, + MockChainBuilder, + assert_transaction_executor_error, +}; use rand::SeedableRng; use rand::rngs::SmallRng; use rstest::rstest; @@ -2072,3 +2087,145 @@ fn pswap_parse_inputs_roundtrip() { // Verify requested amount from value word assert_eq!(parsed.min_requested_amount(), 25, "Requested amount should be 25"); } + +/// Regression test for the offered-asset drain (issue #3601, PSWAP leg). +/// +/// A PSWAP note offers 1000 USDC for a minimum of 100 ETH. The consuming account exposes an +/// `@account_procedure` that performs indexed input-note asset removal +/// (`input_note::remove_asset`), which the kernel permits only from the native-account context. An +/// earlier helper input note (consumed at index 0) calls that procedure to drain 900 USDC out of +/// the PSWAP note (index 1) into the consumer's own vault before the PSWAP script runs, leaving +/// only 100 USDC in the note. +/// +/// Pricing the fill against that 100 USDC residue - rather than the 1000 USDC the note was funded +/// with - would let the consumer keep the drained 900 for free while the creator's remainder note +/// absorbs the loss. The single-asset assertion still passes because a partially-removed fungible +/// keeps its slot with the reduced value. +/// +/// The fix binds the offered amount to the note's initial assets, so the drained note now aborts +/// the whole transaction with `ERR_PSWAP_OFFERED_ASSET_ALTERED` instead of pricing against the +/// residue. +#[tokio::test] +async fn pswap_note_offered_asset_drain_is_rejected_test() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let usdc_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 10_000, Some(1_000))?; + let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 10_000, Some(100))?; + + let alice = AccountIdBuilder::new().build_with_seed([1; 32]); + + // A component that drains a specified asset from an input note by index into the account's own + // vault. Indexed removal is gated on the native-account context, so exposing it as an account + // procedure lets any note in the transaction reach it via `call`. + let drain_component = AccountComponent::new( + CodeBuilder::default().compile_component_code( + "attacker_account", + " + use miden::protocol::asset + use miden::protocol::input_note + use miden::protocol::native_account + + #! Removes the given asset from the input note at `note_index` and credits it to this + #! account's vault. + #! + #! Inputs: [ASSET_ID, ASSET_VALUE, note_index, pad(7)] + #! Outputs: [pad(16)] + @account_procedure + @locals(8) + pub proc drain_note_asset + # keep a copy of the asset so it can be credited to the vault after removal + dupw.1 dupw.1 locaddr.0 exec.asset::store + # => [ASSET_ID, ASSET_VALUE, note_index, pad(7)] + + exec.input_note::remove_asset + # => [FINAL_ASSET_VALUE, pad(7)] + + dropw + # => [pad(7)] + + # credit the drained asset to the consuming account's vault + locaddr.0 exec.asset::load exec.native_account::add_asset dropw + # => [pad(7)] + end + ", + )?, + Vec::new(), + AccountComponentMetadata::mock("attacker_account"), + )?; + + // The consuming account bundles the basic wallet (required by the PSWAP script) with the + // indexed-removal procedure, and holds enough ETH to fill the swap. + let consumer = builder.add_account_from_builder( + BASIC_AUTH, + Account::builder([9; 32]) + .account_type(AccountType::Public) + .with_component(BasicWallet) + .with_component(drain_component.clone()) + .with_assets([FungibleAsset::new(eth_faucet.id(), 100)?.into()]), + AccountState::Exists, + )?; + + // Alice's PSWAP note offers 1000 USDC for a minimum of 100 ETH. + let offered_asset = FungibleAsset::new(usdc_faucet.id(), 1_000)?; + let min_requested_asset = FungibleAsset::new(eth_faucet.id(), 100)?; + let (_, pswap_note) = build_pswap_note( + &mut builder, + alice, + offered_asset, + min_requested_asset, + NoteType::Public, + )?; + + // Helper note (input note index 0) that drains 900 of the 1000 offered USDC out of the PSWAP + // note (input note index 1) via the consuming account's procedure. + let drained = FungibleAsset::new(usdc_faucet.id(), 900)?; + let pswap_input_note_index = 1u8; + let helper_code = format!( + r#" + use miden::core::sys + + @note_script + pub proc main + # Drain the offered asset from the PSWAP note by index, via the native account's + # procedure, before the PSWAP script gets to consume its own remaining assets. + push.0.0.0.0 push.0.0.0 + push.{pswap_input_note_index} + push.{asset_value} + push.{asset_id} + call.::attacker_account::drain_note_asset + exec.sys::truncate_stack + end + "#, + asset_value = drained.to_value_word(), + asset_id = drained.to_id_word(), + ); + let helper_script = CodeBuilder::with_mock_packages() + .with_dynamically_linked_package(drain_component.component_code())? + .compile_note_script(helper_code)?; + let helper_note = NoteBuilder::new(alice, RandomCoin::new(Word::from([7, 7, 7, 7u32]))) + .note_type(NoteType::Public) + .script(helper_script) + .build()?; + // Commit the helper note on-chain so it can be consumed as an authenticated input note, which + // keeps the input-note ordering explicit: helper at index 0, PSWAP at index 1. + builder.add_output_note(RawOutputNote::Full(helper_note.clone())); + + let mock_chain = builder.build()?; + + // A 50-of-100 ETH partial fill: below the minimum, so absent the fix a remainder note would be + // created and priced against the drained residue. + let mut note_args_map = BTreeMap::new(); + note_args_map.insert(pswap_note.id(), PswapNote::create_args(50, 0)?); + + let result = mock_chain + .build_transaction(consumer.id()) + .authenticated_input_notes([helper_note.id(), pswap_note.id()]) + .extend_note_args(note_args_map) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_PSWAP_OFFERED_ASSET_ALTERED); + + Ok(()) +} From 8e26fa04e58867821906fb7959ca0396324fda88 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 20 Aug 2026 16:01:57 +0000 Subject: [PATCH 2/7] docs(standards): trim pswap fix comments Reduce the inline comments added with the offered-asset binding to match the surrounding file, and drop the counterfactual/pre-fix narration from the doc comments and the regression test. Co-Authored-By: Claude Fable 5 --- .../asm/standards/notes/pswap.masm | 18 +++++----------- crates/miden-standards/src/note/pswap.rs | 8 +++---- crates/miden-testing/tests/scripts/pswap.rs | 21 +++++-------------- 3 files changed, 13 insertions(+), 34 deletions(-) diff --git a/crates/miden-standards/asm/standards/notes/pswap.masm b/crates/miden-standards/asm/standards/notes/pswap.masm index ffcf14eede..7f02586a17 100644 --- a/crates/miden-standards/asm/standards/notes/pswap.masm +++ b/crates/miden-standards/asm/standards/notes/pswap.masm @@ -450,14 +450,9 @@ proc is_consumer_creator # => [is_creator] end -#! Loads the offered asset from the active note, bound to the note's initial assets so a fill is -#! always priced against the amount the creator funded rather than whatever remains at consumption. -#! -#! Asset removals (e.g. an earlier native-account indexed removal via `input_note::remove_asset`) -#! only shrink the assets remaining in the note; they never affect its initial assets. This -#! procedure therefore asserts that the single remaining offered asset still equals, word-for-word, -#! the single asset the note was created with. Any prior removal from the PSWAP note aborts the whole -#! transaction rather than letting the payout and remainder be sized from the residue. +#! Loads the note's single offered asset, asserting it still equals the asset the note was created +#! with. Removals only shrink a note's remaining assets, never its initial assets, so binding the +#! two ties the fill price to the amount the creator funded; any prior removal aborts the transaction. #! #! Inputs: [] #! Outputs: [ASSET_ID, ASSET_VALUE] @@ -469,33 +464,30 @@ end #! @locals(16) proc load_offered_asset - # Remove the note's remaining assets and require exactly one. locaddr.0 exec.active_note::remove_all_assets # => [num_assets] push.1 eq assert.err=ERR_PSWAP_WRONG_NUMBER_OF_ASSETS # => [] - # Fetch the note's initial assets (unaffected by removals) and require exactly one. + # initial assets are unaffected by removals; require exactly one locaddr.8 exec.active_note::get_initial_assets # => [num_initial_assets] push.1 eq assert.err=ERR_PSWAP_OFFERED_ASSET_ALTERED # => [] - # Bind the offered asset to what the note was funded with: the remaining offered asset must match - # the initial offered asset word-for-word (asset ID and value). locaddr.0 exec.asset::load # => [ASSET_ID, ASSET_VALUE] locaddr.8 exec.asset::load # => [INITIAL_ASSET_ID, INITIAL_ASSET_VALUE, ASSET_ID, ASSET_VALUE] + # the remaining offered asset must equal the initial one word-for-word (ID and value) movupw.2 assert_eqw.err=ERR_PSWAP_OFFERED_ASSET_ALTERED assert_eqw.err=ERR_PSWAP_OFFERED_ASSET_ALTERED # => [] - # Re-load the offered asset (still in memory) as the return value. locaddr.0 exec.asset::load # => [ASSET_ID, ASSET_VALUE] end diff --git a/crates/miden-standards/src/note/pswap.rs b/crates/miden-standards/src/note/pswap.rs index 1ba7f4817e..d56935861a 100644 --- a/crates/miden-standards/src/note/pswap.rs +++ b/crates/miden-standards/src/note/pswap.rs @@ -268,11 +268,9 @@ impl From for NoteAttachment { /// set the `attachment` to a [`NetworkAccountTarget`](crate::note::NetworkAccountTarget) /// via the builder. /// -/// Fills are priced against the note's initial offered asset - the amount the creator funded, as -/// recorded at note creation - rather than whatever remains at consumption time. The on-chain -/// script asserts that the two are equal, so any pre-consumption asset removal (e.g. an earlier -/// native-account indexed removal) aborts the transaction instead of letting a fill be priced -/// against the residue. +/// Fills are priced against the note's initial offered asset - the amount the creator funded at +/// note creation - which the on-chain script asserts still matches the asset remaining at +/// consumption. Any pre-consumption removal from the note therefore aborts the transaction. #[derive(Debug, Clone, bon::Builder)] #[builder(finish_fn(vis = "", name = build_internal))] pub struct PswapNote { diff --git a/crates/miden-testing/tests/scripts/pswap.rs b/crates/miden-testing/tests/scripts/pswap.rs index e7cb08ab8b..2321f9bdc2 100644 --- a/crates/miden-testing/tests/scripts/pswap.rs +++ b/crates/miden-testing/tests/scripts/pswap.rs @@ -2091,20 +2091,10 @@ fn pswap_parse_inputs_roundtrip() { /// Regression test for the offered-asset drain (issue #3601, PSWAP leg). /// /// A PSWAP note offers 1000 USDC for a minimum of 100 ETH. The consuming account exposes an -/// `@account_procedure` that performs indexed input-note asset removal -/// (`input_note::remove_asset`), which the kernel permits only from the native-account context. An -/// earlier helper input note (consumed at index 0) calls that procedure to drain 900 USDC out of -/// the PSWAP note (index 1) into the consumer's own vault before the PSWAP script runs, leaving -/// only 100 USDC in the note. -/// -/// Pricing the fill against that 100 USDC residue - rather than the 1000 USDC the note was funded -/// with - would let the consumer keep the drained 900 for free while the creator's remainder note -/// absorbs the loss. The single-asset assertion still passes because a partially-removed fungible -/// keeps its slot with the reduced value. -/// -/// The fix binds the offered amount to the note's initial assets, so the drained note now aborts -/// the whole transaction with `ERR_PSWAP_OFFERED_ASSET_ALTERED` instead of pricing against the -/// residue. +/// `@account_procedure` performing indexed input-note asset removal (`input_note::remove_asset`), +/// permitted only from the native-account context. An earlier helper input note (index 0) calls it +/// to drain 900 USDC out of the PSWAP note (index 1) before the PSWAP script runs. A partial fill +/// must then abort with `ERR_PSWAP_OFFERED_ASSET_ALTERED`. #[tokio::test] async fn pswap_note_offered_asset_drain_is_rejected_test() -> anyhow::Result<()> { let mut builder = MockChain::builder(); @@ -2212,8 +2202,7 @@ async fn pswap_note_offered_asset_drain_is_rejected_test() -> anyhow::Result<()> let mock_chain = builder.build()?; - // A 50-of-100 ETH partial fill: below the minimum, so absent the fix a remainder note would be - // created and priced against the drained residue. + // A 50-of-100 ETH partial fill, which exercises the remainder-note pricing path. let mut note_args_map = BTreeMap::new(); note_args_map.insert(pswap_note.id(), PswapNote::create_args(50, 0)?); From f65a87628defb3c68da038fed67b50460e8db0b8 Mon Sep 17 00:00:00 2001 From: Marti Date: Thu, 20 Aug 2026 22:15:42 +0200 Subject: [PATCH 3/7] Apply suggestions from code review Co-authored-by: Marti --- CHANGELOG.md | 2 +- crates/miden-standards/asm/standards/notes/pswap.masm | 6 ++---- crates/miden-standards/src/note/pswap.rs | 4 +--- crates/miden-testing/tests/scripts/pswap.rs | 8 ++------ 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de78e99f99..d128d8b5e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,7 @@ - Fixed `input_note::remove_asset` succeeding when asked to remove an empty or malformed asset ID instead of reporting the asset as not found ([#3592](https://github.com/0xMiden/protocol/pull/3607)). - Faucet asset-callback procedure roots are now verified against the faucet's account code before dispatch, so a misconfigured callback root can no longer make an asset nontransferable ([#3612](https://github.com/0xMiden/protocol/pull/3612)). - [BREAKING] Enforced the limit of 1024 per asset delta op for added and removed account vault deltas inside and outside the tx kernel ([#3623](https://github.com/0xMiden/protocol/pull/3623)). -- [BREAKING] Priced PSWAP fills against the note's initial offered asset rather than its remaining assets, so a pre-consumption indexed asset removal can no longer drain the offered side; the PSWAP note script root and PSWAP note IDs change ([#3601](https://github.com/0xMiden/protocol/issues/3601)). +- [BREAKING] Priced PSWAP fills against the note's initial offered asset rather than its remaining assets ([#3601](https://github.com/0xMiden/protocol/issues/3601)). ## v0.16.0 (2026-08-17) diff --git a/crates/miden-standards/asm/standards/notes/pswap.masm b/crates/miden-standards/asm/standards/notes/pswap.masm index 7f02586a17..a1c14f8cec 100644 --- a/crates/miden-standards/asm/standards/notes/pswap.masm +++ b/crates/miden-standards/asm/standards/notes/pswap.masm @@ -450,9 +450,7 @@ proc is_consumer_creator # => [is_creator] end -#! Loads the note's single offered asset, asserting it still equals the asset the note was created -#! with. Removals only shrink a note's remaining assets, never its initial assets, so binding the -#! two ties the fill price to the amount the creator funded; any prior removal aborts the transaction. +#! Loads the note's single offered asset, asserting it equals the amount the note was created with. #! #! Inputs: [] #! Outputs: [ASSET_ID, ASSET_VALUE] @@ -483,7 +481,7 @@ proc load_offered_asset locaddr.8 exec.asset::load # => [INITIAL_ASSET_ID, INITIAL_ASSET_VALUE, ASSET_ID, ASSET_VALUE] - # the remaining offered asset must equal the initial one word-for-word (ID and value) + # the remaining offered asset must equal the initial one movupw.2 assert_eqw.err=ERR_PSWAP_OFFERED_ASSET_ALTERED assert_eqw.err=ERR_PSWAP_OFFERED_ASSET_ALTERED # => [] diff --git a/crates/miden-standards/src/note/pswap.rs b/crates/miden-standards/src/note/pswap.rs index d56935861a..0074e820b8 100644 --- a/crates/miden-standards/src/note/pswap.rs +++ b/crates/miden-standards/src/note/pswap.rs @@ -268,9 +268,7 @@ impl From for NoteAttachment { /// set the `attachment` to a [`NetworkAccountTarget`](crate::note::NetworkAccountTarget) /// via the builder. /// -/// Fills are priced against the note's initial offered asset - the amount the creator funded at -/// note creation - which the on-chain script asserts still matches the asset remaining at -/// consumption. Any pre-consumption removal from the note therefore aborts the transaction. +/// Fills are priced against the note's initial offered asset. #[derive(Debug, Clone, bon::Builder)] #[builder(finish_fn(vis = "", name = build_internal))] pub struct PswapNote { diff --git a/crates/miden-testing/tests/scripts/pswap.rs b/crates/miden-testing/tests/scripts/pswap.rs index 2321f9bdc2..12af6b8653 100644 --- a/crates/miden-testing/tests/scripts/pswap.rs +++ b/crates/miden-testing/tests/scripts/pswap.rs @@ -2105,8 +2105,7 @@ async fn pswap_note_offered_asset_drain_is_rejected_test() -> anyhow::Result<()> let alice = AccountIdBuilder::new().build_with_seed([1; 32]); // A component that drains a specified asset from an input note by index into the account's own - // vault. Indexed removal is gated on the native-account context, so exposing it as an account - // procedure lets any note in the transaction reach it via `call`. + // vault. let drain_component = AccountComponent::new( CodeBuilder::default().compile_component_code( "attacker_account", @@ -2143,8 +2142,6 @@ async fn pswap_note_offered_asset_drain_is_rejected_test() -> anyhow::Result<()> AccountComponentMetadata::mock("attacker_account"), )?; - // The consuming account bundles the basic wallet (required by the PSWAP script) with the - // indexed-removal procedure, and holds enough ETH to fill the swap. let consumer = builder.add_account_from_builder( BASIC_AUTH, Account::builder([9; 32]) @@ -2196,8 +2193,7 @@ async fn pswap_note_offered_asset_drain_is_rejected_test() -> anyhow::Result<()> .note_type(NoteType::Public) .script(helper_script) .build()?; - // Commit the helper note on-chain so it can be consumed as an authenticated input note, which - // keeps the input-note ordering explicit: helper at index 0, PSWAP at index 1. + // Commit the helper note so it can be consumed as an authenticated input note (index 0). builder.add_output_note(RawOutputNote::Full(helper_note.clone())); let mock_chain = builder.build()?; From 7d93316aa9d0fde4d30ec0287de6a58b66b0f18d Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 20 Aug 2026 20:23:37 +0000 Subject: [PATCH 4/7] refactor(standards): name load_offered_asset local offsets Replace the bare locaddr.0 / locaddr.8 in load_offered_asset with named OFFERED_REMAINING_ASSET_PTR / OFFERED_INITIAL_ASSET_PTR constants, matching the per-proc local-offset convention used elsewhere in the file. Co-Authored-By: Claude Fable 5 --- .../miden-standards/asm/standards/notes/pswap.masm | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/miden-standards/asm/standards/notes/pswap.masm b/crates/miden-standards/asm/standards/notes/pswap.masm index a1c14f8cec..dd0d6e18b6 100644 --- a/crates/miden-standards/asm/standards/notes/pswap.masm +++ b/crates/miden-standards/asm/standards/notes/pswap.masm @@ -64,6 +64,10 @@ const PSWAP_CREATOR_PREFIX_ITEM = STORAGE_PTR + 6 const CALC_FILL_AMOUNT = 0 const CALC_FILL_REFERENCE = 1 +# load_offered_asset locals (one asset occupies 8 locals, so the two buffers are 8 apart) +const OFFERED_REMAINING_ASSET_PTR = 0 +const OFFERED_INITIAL_ASSET_PTR = 8 + # create_p2id_note locals const P2ID_NOTE_IDX = 0 const P2ID_REQUESTED_FAUCET_SUFFIX = 1 @@ -462,23 +466,23 @@ end #! @locals(16) proc load_offered_asset - locaddr.0 exec.active_note::remove_all_assets + locaddr.OFFERED_REMAINING_ASSET_PTR exec.active_note::remove_all_assets # => [num_assets] push.1 eq assert.err=ERR_PSWAP_WRONG_NUMBER_OF_ASSETS # => [] # initial assets are unaffected by removals; require exactly one - locaddr.8 exec.active_note::get_initial_assets + locaddr.OFFERED_INITIAL_ASSET_PTR exec.active_note::get_initial_assets # => [num_initial_assets] push.1 eq assert.err=ERR_PSWAP_OFFERED_ASSET_ALTERED # => [] - locaddr.0 exec.asset::load + locaddr.OFFERED_REMAINING_ASSET_PTR exec.asset::load # => [ASSET_ID, ASSET_VALUE] - locaddr.8 exec.asset::load + locaddr.OFFERED_INITIAL_ASSET_PTR exec.asset::load # => [INITIAL_ASSET_ID, INITIAL_ASSET_VALUE, ASSET_ID, ASSET_VALUE] # the remaining offered asset must equal the initial one @@ -486,7 +490,7 @@ proc load_offered_asset assert_eqw.err=ERR_PSWAP_OFFERED_ASSET_ALTERED # => [] - locaddr.0 exec.asset::load + locaddr.OFFERED_REMAINING_ASSET_PTR exec.asset::load # => [ASSET_ID, ASSET_VALUE] end From f86895de58a1e60bd6d2da1b1d97321a32f25dfe Mon Sep 17 00:00:00 2001 From: Marti Date: Thu, 20 Aug 2026 22:32:33 +0200 Subject: [PATCH 5/7] Apply suggestion from @mmagician --- crates/miden-standards/asm/standards/notes/pswap.masm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/miden-standards/asm/standards/notes/pswap.masm b/crates/miden-standards/asm/standards/notes/pswap.masm index dd0d6e18b6..b5ac60a605 100644 --- a/crates/miden-standards/asm/standards/notes/pswap.masm +++ b/crates/miden-standards/asm/standards/notes/pswap.masm @@ -64,7 +64,7 @@ const PSWAP_CREATOR_PREFIX_ITEM = STORAGE_PTR + 6 const CALC_FILL_AMOUNT = 0 const CALC_FILL_REFERENCE = 1 -# load_offered_asset locals (one asset occupies 8 locals, so the two buffers are 8 apart) +# Procedure locals of load_offered_asset const OFFERED_REMAINING_ASSET_PTR = 0 const OFFERED_INITIAL_ASSET_PTR = 8 From eee210b18b6cb60f6f3e9b492a051a17c1a4d931 Mon Sep 17 00:00:00 2001 From: Marti Date: Fri, 21 Aug 2026 15:15:57 +0200 Subject: [PATCH 6/7] Apply suggestions from code review Co-authored-by: zeapoz Co-authored-by: Philipp Gackstatter --- crates/miden-standards/asm/standards/notes/pswap.masm | 4 ++-- crates/miden-testing/tests/scripts/pswap.rs | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/miden-standards/asm/standards/notes/pswap.masm b/crates/miden-standards/asm/standards/notes/pswap.masm index b5ac60a605..51d8ba6d0b 100644 --- a/crates/miden-standards/asm/standards/notes/pswap.masm +++ b/crates/miden-standards/asm/standards/notes/pswap.masm @@ -454,7 +454,7 @@ proc is_consumer_creator # => [is_creator] end -#! Loads the note's single offered asset, asserting it equals the amount the note was created with. +#! Loads the note's single offered asset, asserting it equals the amount the note was created with. #! #! Inputs: [] #! Outputs: [ASSET_ID, ASSET_VALUE] @@ -476,7 +476,7 @@ proc load_offered_asset locaddr.OFFERED_INITIAL_ASSET_PTR exec.active_note::get_initial_assets # => [num_initial_assets] - push.1 eq assert.err=ERR_PSWAP_OFFERED_ASSET_ALTERED + eq.1 assert.err=ERR_PSWAP_OFFERED_ASSET_ALTERED # => [] locaddr.OFFERED_REMAINING_ASSET_PTR exec.asset::load diff --git a/crates/miden-testing/tests/scripts/pswap.rs b/crates/miden-testing/tests/scripts/pswap.rs index 12af6b8653..8a99f42791 100644 --- a/crates/miden-testing/tests/scripts/pswap.rs +++ b/crates/miden-testing/tests/scripts/pswap.rs @@ -2194,7 +2194,8 @@ async fn pswap_note_offered_asset_drain_is_rejected_test() -> anyhow::Result<()> .script(helper_script) .build()?; // Commit the helper note so it can be consumed as an authenticated input note (index 0). - builder.add_output_note(RawOutputNote::Full(helper_note.clone())); + let helper_note_id = helper_note.id(); + builder.add_output_note(RawOutputNote::Full(helper_note)); let mock_chain = builder.build()?; From ca6a2ecd640e073659ae3bc6432a599ec5f6d7e2 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Fri, 21 Aug 2026 13:36:55 +0000 Subject: [PATCH 7/7] fix(standards): use helper_note_id instead of the moved helper_note in PSWAP drain test --- crates/miden-testing/tests/scripts/pswap.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/miden-testing/tests/scripts/pswap.rs b/crates/miden-testing/tests/scripts/pswap.rs index 8a99f42791..35d2fe16aa 100644 --- a/crates/miden-testing/tests/scripts/pswap.rs +++ b/crates/miden-testing/tests/scripts/pswap.rs @@ -2205,7 +2205,7 @@ async fn pswap_note_offered_asset_drain_is_rejected_test() -> anyhow::Result<()> let result = mock_chain .build_transaction(consumer.id()) - .authenticated_input_notes([helper_note.id(), pswap_note.id()]) + .authenticated_input_notes([helper_note_id, pswap_note.id()]) .extend_note_args(note_args_map) .build()? .execute()