use std::collections::BTreeMap;
use std::slice;
use miden_protocol::account::auth::AuthScheme;
use miden_protocol::account::{Account, AccountId, AccountType, AccountVaultDelta};
use miden_protocol::asset::{Asset, AssetAmount, FungibleAsset};
use miden_protocol::crypto::rand::{FeltRng, RandomCoin};
use miden_protocol::errors::MasmError;
use miden_protocol::note::{Note, NoteAttachments, NoteType};
use miden_protocol::transaction::RawOutputNote;
use miden_protocol::{Felt, ONE, Word, ZERO};
use miden_standards::account::wallets::BasicWallet;
use miden_standards::errors::standards::{
ERR_PSWAP_FILL_EXCEEDS_REQUESTED,
ERR_PSWAP_FILL_SUM_OVERFLOW,
ERR_PSWAP_NOT_VALID_ASSET_AMOUNT,
};
use miden_standards::note::{PswapNote, PswapNoteAttachment, PswapNoteStorage};
use miden_standards::testing::note::NoteBuilder;
use miden_testing::{Auth, MockChain, MockChainBuilder, assert_transaction_executor_error};
use rand::SeedableRng;
use rand::rngs::SmallRng;
use rstest::rstest;
const BASIC_AUTH: Auth = Auth::BasicAuth {
auth_scheme: AuthScheme::Falcon512Poseidon2,
};
fn first_attachment_word(attachments: &NoteAttachments) -> Word {
let content = attachments.get(0).expect("expected at least one attachment").content();
assert_eq!(content.num_words(), 1, "expected single word attachment");
content.as_words()[0]
}
fn build_pswap_note(
builder: &mut MockChainBuilder,
sender: AccountId,
offered_asset: FungibleAsset,
requested_asset: FungibleAsset,
note_type: NoteType,
) -> anyhow::Result<(PswapNote, Note)> {
let serial_number = builder.rng_mut().draw_word();
let storage = PswapNoteStorage::builder()
.requested_asset(requested_asset)
.creator_account_id(sender)
.build();
let pswap = PswapNote::builder()
.sender(sender)
.storage(storage)
.serial_number(serial_number)
.note_type(note_type)
.offered_asset(offered_asset)
.build()?;
let note: Note = pswap.clone().into();
builder.add_output_note(RawOutputNote::Full(note.clone()));
Ok((pswap, note))
}
#[track_caller]
fn assert_fungible_asset_eq(asset: &Asset, expected: FungibleAsset) {
match asset {
Asset::Fungible(f) => {
assert_eq!(f.faucet_id(), expected.faucet_id(), "faucet id mismatch");
assert_eq!(
f.amount(),
expected.amount(),
"amount mismatch (expected {}, got {})",
expected.amount(),
f.amount()
);
},
_ => panic!("expected fungible asset, got non-fungible"),
}
}
#[track_caller]
fn assert_vault_added_removed(
vault_delta: &AccountVaultDelta,
expected_added: FungibleAsset,
expected_removed: FungibleAsset,
) {
let added: Vec<Asset> = vault_delta.added_assets().collect();
let removed: Vec<Asset> = vault_delta.removed_assets().collect();
assert_eq!(added.len(), 1, "expected exactly 1 added asset");
assert_eq!(removed.len(), 1, "expected exactly 1 removed asset");
assert_fungible_asset_eq(&added[0], expected_added);
assert_fungible_asset_eq(&removed[0], expected_removed);
}
#[track_caller]
fn assert_vault_single_added(vault_delta: &AccountVaultDelta, expected: FungibleAsset) {
let added: Vec<Asset> = vault_delta.added_assets().collect();
assert_eq!(added.len(), 1, "expected exactly 1 added asset");
assert_fungible_asset_eq(&added[0], expected);
}
#[rstest]
#[case::partial_public(NoteType::Public, 20)]
#[case::full_public(NoteType::Public, 25)]
#[case::partial_private(NoteType::Private, 20)]
#[case::full_private(NoteType::Private, 25)]
#[tokio::test]
async fn pswap_note_alice_reconstructs_and_consumes_p2id(
#[case] payback_note_type: NoteType,
#[case] fill_amount: u64,
) -> 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(), fill_amount)?.into()],
)?;
let offered_asset = FungibleAsset::new(usdc_faucet.id(), 50)?;
let requested_asset = FungibleAsset::new(eth_faucet.id(), 25)?;
let is_partial = fill_amount < u64::from(requested_asset.amount());
let mut rng = RandomCoin::new(Word::default());
let serial_number = rng.draw_word();
let storage = PswapNoteStorage::builder()
.requested_asset(requested_asset)
.creator_account_id(alice.id())
.payback_note_type(payback_note_type)
.build();
let pswap = PswapNote::builder()
.sender(alice.id())
.storage(storage)
.serial_number(serial_number)
.note_type(NoteType::Public)
.offered_asset(offered_asset)
.build()?;
let pswap_note: Note = pswap.clone().into();
builder.add_output_note(RawOutputNote::Full(pswap_note.clone()));
let mut mock_chain = builder.build()?;
let mut note_args_map = BTreeMap::new();
note_args_map.insert(pswap_note.id(), PswapNote::create_args(fill_amount, 0)?);
let (p2id_note, remainder_pswap) =
pswap.execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), fill_amount)?), None)?;
let mut expected_output_notes = vec![RawOutputNote::Full(p2id_note.clone())];
let predicted_remainder = if is_partial {
let r = remainder_pswap.expect("partial fill should produce remainder");
let rn = Note::from(r);
expected_output_notes.push(RawOutputNote::Full(rn.clone()));
Some(rn)
} else {
assert!(remainder_pswap.is_none(), "full fill should not produce a remainder");
None
};
let tx_context = mock_chain
.build_tx_context(bob.id(), &[pswap_note.id()], &[])?
.extend_note_args(note_args_map)
.extend_expected_output_notes(expected_output_notes)
.build()?;
let executed_transaction = tx_context.execute().await?;
mock_chain.add_pending_executed_transaction(&executed_transaction)?;
mock_chain.prove_next_block()?;
let output_p2id = executed_transaction.output_notes().get_note(0);
let attachment_word = first_attachment_word(output_p2id.attachments());
let fill_amount_from_aux = attachment_word[0].as_canonical_u64();
assert_eq!(fill_amount_from_aux, fill_amount, "fill amount from aux should match the case");
assert_eq!(
first_attachment_word(p2id_note.attachments()),
attachment_word,
"Rust-predicted P2ID attachment does not match the MASM-produced one",
);
let payback_attachment =
PswapNoteAttachment::new(AssetAmount::new(fill_amount_from_aux)?, pswap.order_id(), 1);
let reconstructed_payback =
pswap.payback_note(output_p2id.metadata().sender(), &payback_attachment)?;
assert_eq!(
reconstructed_payback.recipient().digest(),
output_p2id.recipient_digest(),
"Alice's reconstructed P2ID recipient does not match the actual output"
);
if is_partial {
let output_remainder = executed_transaction.output_notes().get_note(1);
let remainder_attachment_word = first_attachment_word(output_remainder.attachments());
let amt_payout_from_attachment = remainder_attachment_word[0].as_canonical_u64();
let expected_payout = pswap.calculate_offered_for_requested(fill_amount_from_aux)?;
assert_eq!(
amt_payout_from_attachment, expected_payout,
"remainder aux should carry amt_payout matching the Rust-side calc",
);
let remaining_requested =
(requested_asset.amount() - AssetAmount::new(fill_amount_from_aux)?)?;
let remaining_offered =
(pswap.offered_asset().amount() - AssetAmount::new(amt_payout_from_attachment)?)?;
let remainder_attachment = PswapNoteAttachment::new(
AssetAmount::new(amt_payout_from_attachment)?,
pswap.order_id(),
1,
);
let reconstructed_remainder = pswap.remainder_note(
output_remainder.metadata().sender(),
&remainder_attachment,
remaining_offered,
remaining_requested,
)?;
let predicted_remainder = predicted_remainder
.as_ref()
.expect("predicted remainder must exist on partial fill");
assert_eq!(
predicted_remainder.recipient().digest(),
output_remainder.recipient_digest(),
"Rust-predicted remainder recipient does not match executed output",
);
assert_eq!(
reconstructed_remainder.details_commitment(),
output_remainder.details_commitment(),
"reconstructed remainder commitment must match on-chain leaf",
);
}
let tx_context = mock_chain
.build_tx_context(alice.id(), &[], slice::from_ref(&reconstructed_payback))?
.build()?;
let executed_transaction = tx_context.execute().await?;
let vault_delta = executed_transaction.account_delta().vault();
assert_vault_single_added(vault_delta, FungibleAsset::new(eth_faucet.id(), fill_amount)?);
Ok(())
}
#[tokio::test]
async fn pswap_attachment_layout_matches_masm_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 usdc_50 = FungibleAsset::new(usdc_faucet.id(), 50)?;
let eth_20 = FungibleAsset::new(eth_faucet.id(), 20)?;
let eth_25 = FungibleAsset::new(eth_faucet.id(), 25)?;
let alice = builder.add_existing_wallet_with_assets(BASIC_AUTH, [usdc_50.into()])?;
let bob = builder.add_existing_wallet_with_assets(BASIC_AUTH, [eth_20.into()])?;
let (pswap, pswap_note) =
build_pswap_note(&mut builder, alice.id(), usdc_50, eth_25, NoteType::Public)?;
let mock_chain = builder.build()?;
let fill_amount = 20u64;
let expected_payout = 40u64; let order_id = pswap.order_id();
let expected_depth = 1u64;
let mut note_args_map = BTreeMap::new();
note_args_map.insert(pswap_note.id(), PswapNote::create_args(fill_amount, 0)?);
let (p2id_note, remainder_pswap) = pswap.execute(bob.id(), Some(eth_20), None)?;
let remainder_note =
Note::from(remainder_pswap.expect("partial fill should produce remainder"));
let tx_context = mock_chain
.build_tx_context(bob.id(), &[pswap_note.id()], &[])?
.extend_note_args(note_args_map)
.extend_expected_output_notes(vec![
RawOutputNote::Full(p2id_note.clone()),
RawOutputNote::Full(remainder_note.clone()),
])
.build()?;
let executed_transaction = tx_context.execute().await?;
let output_notes = executed_transaction.output_notes();
assert_eq!(output_notes.num_notes(), 2, "expected P2ID + remainder");
let p2id_attachments = output_notes.get_note(0).attachments();
let remainder_attachments = output_notes.get_note(1).attachments();
assert_eq!(p2id_attachments.num_attachments(), 1, "payback expects 1 attachment");
assert_eq!(remainder_attachments.num_attachments(), 1, "remainder expects 1 attachment");
let p2id_att = p2id_attachments.get(0).expect("payback attachment present");
let remainder_att = remainder_attachments.get(0).expect("remainder attachment present");
assert_eq!(
p2id_att.attachment_scheme(),
PswapNote::PSWAP_ATTACHMENT_SCHEME,
"payback must use PSWAP_ATTACHMENT_SCHEME",
);
assert_eq!(
remainder_att.attachment_scheme(),
PswapNote::PSWAP_ATTACHMENT_SCHEME,
"remainder must use PSWAP_ATTACHMENT_SCHEME",
);
let expected_p2id_word = Word::from([
Felt::try_from(fill_amount).expect("fill_amount fits in a felt"),
order_id,
Felt::try_from(expected_depth).expect("depth fits in a felt"),
ZERO,
]);
assert_eq!(
p2id_att.content().as_words()[0],
expected_p2id_word,
"P2ID attachment word mismatch: expected [fill_amount, order_id, depth, 0]",
);
let expected_remainder_word = Word::from([
Felt::try_from(expected_payout).expect("amt_payout fits in a felt"),
order_id,
Felt::try_from(expected_depth).expect("depth fits in a felt"),
ZERO,
]);
assert_eq!(
remainder_att.content().as_words()[0],
expected_remainder_word,
"remainder attachment word mismatch: expected [amt_payout, order_id, depth, 0]",
);
assert_eq!(
first_attachment_word(p2id_note.attachments()),
p2id_att.content().as_words()[0],
"Rust-predicted P2ID attachment does not match MASM output",
);
assert_eq!(
first_attachment_word(remainder_note.attachments()),
remainder_att.content().as_words()[0],
"Rust-predicted remainder attachment does not match MASM output",
);
assert_eq!(order_id, pswap.serial_number()[1], "order_id should equal serial[1]");
Ok(())
}
#[rstest]
#[case::full_public(4, NoteType::Public, false)]
#[case::full_private(4, NoteType::Private, false)]
#[case::partial_public(3, NoteType::Public, false)]
#[case::network_full_fill(4, NoteType::Public, true)]
#[tokio::test]
async fn pswap_fill_test(
#[case] fill_base: u64,
#[case] note_type: NoteType,
#[case] use_network_account: bool,
) -> anyhow::Result<()> {
const AMOUNT_SCALE: u64 = 1_000_000_000_000_000_000;
let fill_amount = fill_base * AMOUNT_SCALE;
let offered_total = 8 * AMOUNT_SCALE; let requested_total = 4 * AMOUNT_SCALE; let max_supply = 9 * AMOUNT_SCALE;
let mut builder = MockChain::builder();
let usdc_faucet =
builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", max_supply, Some(offered_total))?;
let eth_faucet =
builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", max_supply, Some(requested_total))?;
let alice = builder.add_existing_wallet_with_assets(
BASIC_AUTH,
[FungibleAsset::new(usdc_faucet.id(), offered_total)?.into()],
)?;
let consumer_id = if use_network_account {
let seed: [u8; 32] = builder.rng_mut().draw_word().into();
let network_consumer = builder.add_account_from_builder(
BASIC_AUTH,
Account::builder(seed)
.account_type(AccountType::Public)
.with_component(BasicWallet)
.with_assets([FungibleAsset::new(eth_faucet.id(), fill_amount)?.into()]),
miden_testing::AccountState::Exists,
)?;
network_consumer.id()
} else {
let bob = builder.add_existing_wallet_with_assets(
BASIC_AUTH,
[FungibleAsset::new(eth_faucet.id(), fill_amount)?.into()],
)?;
bob.id()
};
let offered_asset = FungibleAsset::new(usdc_faucet.id(), offered_total)?;
let requested_asset = FungibleAsset::new(eth_faucet.id(), requested_total)?;
let (pswap, pswap_note) =
build_pswap_note(&mut builder, alice.id(), offered_asset, requested_asset, note_type)?;
let mut mock_chain = builder.build()?;
let fill_asset = FungibleAsset::new(eth_faucet.id(), fill_amount)?;
let (p2id_note, remainder_pswap) = if use_network_account {
let p2id = pswap.execute_full_fill(consumer_id)?;
(p2id, None)
} else {
pswap.execute(consumer_id, Some(fill_asset), None)?
};
let is_partial = fill_amount < requested_total;
let payout_amount = pswap.calculate_offered_for_requested(fill_amount)?;
let mut expected_notes = vec![RawOutputNote::Full(p2id_note.clone())];
if let Some(remainder) = remainder_pswap {
expected_notes.push(RawOutputNote::Full(Note::from(remainder)));
}
let mut tx_builder = mock_chain
.build_tx_context(consumer_id, &[pswap_note.id()], &[])?
.extend_expected_output_notes(expected_notes);
if !use_network_account {
let mut note_args_map = BTreeMap::new();
note_args_map.insert(pswap_note.id(), PswapNote::create_args(fill_amount, 0)?);
tx_builder = tx_builder.extend_note_args(note_args_map);
}
let tx_context = tx_builder.build()?;
let executed_transaction = tx_context.execute().await?;
let output_notes = executed_transaction.output_notes();
let expected_count = if is_partial { 2 } else { 1 };
assert_eq!(
output_notes.num_notes(),
expected_count,
"expected {expected_count} output notes"
);
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);
assert_fungible_asset_eq(
p2id_assets.iter().next().unwrap(),
FungibleAsset::new(eth_faucet.id(), fill_amount)?,
);
if is_partial {
let remainder_assets = output_notes.get_note(1).assets();
assert_fungible_asset_eq(
remainder_assets.iter().next().unwrap(),
FungibleAsset::new(usdc_faucet.id(), offered_total - payout_amount)?,
);
}
let vault_delta = executed_transaction.account_delta().vault();
assert_vault_added_removed(
vault_delta,
FungibleAsset::new(usdc_faucet.id(), payout_amount)?,
FungibleAsset::new(eth_faucet.id(), fill_amount)?,
);
mock_chain.add_pending_executed_transaction(&executed_transaction)?;
mock_chain.prove_next_block()?;
Ok(())
}
#[tokio::test]
async fn pswap_note_note_fill_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 usdc_50 = FungibleAsset::new(usdc_faucet.id(), 50)?;
let eth_25 = FungibleAsset::new(eth_faucet.id(), 25)?;
let alice = builder.add_existing_wallet_with_assets(BASIC_AUTH, [usdc_50.into()])?;
let bob = builder.add_existing_wallet_with_assets(BASIC_AUTH, [eth_25.into()])?;
let charlie = builder.add_existing_wallet_with_assets(BASIC_AUTH, [])?;
let (alice_pswap, alice_pswap_note) =
build_pswap_note(&mut builder, alice.id(), usdc_50, eth_25, NoteType::Public)?;
let (bob_pswap, bob_pswap_note) =
build_pswap_note(&mut builder, bob.id(), eth_25, usdc_50, NoteType::Public)?;
let mock_chain = builder.build()?;
let mut note_args_map = BTreeMap::new();
note_args_map.insert(alice_pswap_note.id(), PswapNote::create_args(0, 25)?);
note_args_map.insert(bob_pswap_note.id(), PswapNote::create_args(0, 50)?);
let (alice_p2id_note, _) = alice_pswap.execute(charlie.id(), None, Some(eth_25))?;
let (bob_p2id_note, _) = bob_pswap.execute(charlie.id(), None, Some(usdc_50))?;
let tx_context = mock_chain
.build_tx_context(charlie.id(), &[alice_pswap_note.id(), bob_pswap_note.id()], &[])?
.extend_note_args(note_args_map)
.extend_expected_output_notes(vec![
RawOutputNote::Full(alice_p2id_note),
RawOutputNote::Full(bob_p2id_note),
])
.build()?;
let executed_transaction = tx_context.execute().await?;
let output_notes = executed_transaction.output_notes();
assert_eq!(output_notes.num_notes(), 2);
assert!(
output_notes
.iter()
.any(|note| note.assets().iter_fungible().any(|a| a == eth_25)),
"Alice's P2ID note ({eth_25:?}) not found",
);
assert!(
output_notes
.iter()
.any(|note| note.assets().iter_fungible().any(|a| a == usdc_50)),
"Bob's P2ID note ({usdc_50:?}) not found",
);
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_combined_account_fill_and_note_fill_test() -> anyhow::Result<()> {
let mut builder = MockChain::builder();
let usdc_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 1000, Some(200))?;
let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1000, Some(60))?;
let alice_offered = FungibleAsset::new(usdc_faucet.id(), 100)?;
let alice_requested = FungibleAsset::new(eth_faucet.id(), 50)?;
let bob_offered = FungibleAsset::new(eth_faucet.id(), 30)?;
let bob_requested = FungibleAsset::new(usdc_faucet.id(), 60)?;
let charlie_vault_eth = FungibleAsset::new(eth_faucet.id(), 20)?;
let account_fill_eth = charlie_vault_eth;
let note_fill_eth = bob_offered;
let charlie_payout_usdc = FungibleAsset::new(usdc_faucet.id(), 40)?;
let alice = builder.add_existing_wallet_with_assets(BASIC_AUTH, [alice_offered.into()])?;
let bob = builder.add_existing_wallet_with_assets(BASIC_AUTH, [bob_offered.into()])?;
let charlie =
builder.add_existing_wallet_with_assets(BASIC_AUTH, [charlie_vault_eth.into()])?;
let (alice_pswap, alice_pswap_note) = build_pswap_note(
&mut builder,
alice.id(),
alice_offered,
alice_requested,
NoteType::Public,
)?;
let (bob_pswap, bob_pswap_note) =
build_pswap_note(&mut builder, bob.id(), bob_offered, bob_requested, NoteType::Public)?;
let mock_chain = builder.build()?;
let mut note_args_map = BTreeMap::new();
note_args_map.insert(alice_pswap_note.id(), PswapNote::create_args(20, 30)?);
note_args_map.insert(bob_pswap_note.id(), PswapNote::create_args(0, 60)?);
let (alice_p2id_note, alice_remainder) =
alice_pswap.execute(charlie.id(), Some(account_fill_eth), Some(note_fill_eth))?;
assert!(
alice_remainder.is_none(),
"combined fill hits full fill — no remainder expected"
);
let (bob_p2id_note, bob_remainder) =
bob_pswap.execute(charlie.id(), None, Some(bob_requested))?;
assert!(bob_remainder.is_none(), "bob pswap is filled completely via note_fill");
let tx_context = mock_chain
.build_tx_context(charlie.id(), &[alice_pswap_note.id(), bob_pswap_note.id()], &[])?
.extend_note_args(note_args_map)
.extend_expected_output_notes(vec![
RawOutputNote::Full(alice_p2id_note),
RawOutputNote::Full(bob_p2id_note),
])
.build()?;
let executed_transaction = tx_context.execute().await?;
let output_notes = executed_transaction.output_notes();
assert_eq!(output_notes.num_notes(), 2, "expected exactly 2 P2ID output notes");
assert!(
output_notes
.iter()
.any(|note| note.assets().iter_fungible().any(|a| a == alice_requested)),
"Alice's P2ID ({alice_requested:?}) not found",
);
assert!(
output_notes
.iter()
.any(|note| note.assets().iter_fungible().any(|a| a == bob_requested)),
"Bob's P2ID ({bob_requested:?}) not found",
);
let vault_delta = executed_transaction.account_delta().vault();
assert_vault_added_removed(vault_delta, charlie_payout_usdc, charlie_vault_eth);
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 (_, pswap_note) = build_pswap_note(
&mut builder,
alice.id(),
FungibleAsset::new(usdc_faucet.id(), 50)?,
FungibleAsset::new(eth_faucet.id(), 25)?,
NoteType::Public,
)?;
let mock_chain = builder.build()?;
let tx_context = mock_chain.build_tx_context(alice.id(), &[pswap_note.id()], &[])?.build()?;
let executed_transaction = tx_context.execute().await?;
let output_notes = executed_transaction.output_notes();
assert_eq!(output_notes.num_notes(), 0, "Expected 0 output notes for reclaim");
let vault_delta = executed_transaction.account_delta().vault();
assert_vault_single_added(vault_delta, FungibleAsset::new(usdc_faucet.id(), 50)?);
Ok(())
}
#[rstest]
#[case::fill_exceeds_requested(30, 0, ERR_PSWAP_FILL_EXCEEDS_REQUESTED)]
#[case::fill_sum_u64_overflow(1u64 << 63, 1u64 << 63, ERR_PSWAP_FILL_SUM_OVERFLOW)]
#[case::fill_sum_exceeds_max_asset_amount(
FungibleAsset::MAX_AMOUNT.as_u64(),
FungibleAsset::MAX_AMOUNT.as_u64(),
ERR_PSWAP_NOT_VALID_ASSET_AMOUNT
)]
#[tokio::test]
async fn pswap_note_invalid_input_test(
#[case] account_fill: u64,
#[case] note_fill: u64,
#[case] expected_err: MasmError,
) -> 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 (_, pswap_note) = build_pswap_note(
&mut builder,
alice.id(),
FungibleAsset::new(usdc_faucet.id(), 50)?,
FungibleAsset::new(eth_faucet.id(), 25)?,
NoteType::Public,
)?;
let mock_chain = builder.build()?;
let mut note_args_map = BTreeMap::new();
note_args_map.insert(pswap_note.id(), PswapNote::create_args(account_fill, note_fill)?);
let tx_context = mock_chain
.build_tx_context(bob.id(), &[pswap_note.id()], &[])?
.extend_note_args(note_args_map)
.build()?;
let result = tx_context.execute().await;
assert_transaction_executor_error!(result, expected_err);
Ok(())
}
#[tokio::test]
async fn pswap_note_idx_nonzero_regression_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 bob = builder.add_existing_wallet_with_assets(
BASIC_AUTH,
[FungibleAsset::new(eth_faucet.id(), 25)?.into()],
)?;
let (pswap, pswap_note) = build_pswap_note(
&mut builder,
alice.id(),
FungibleAsset::new(usdc_faucet.id(), 50)?,
FungibleAsset::new(eth_faucet.id(), 25)?,
NoteType::Public,
)?;
let dummy_note = NoteBuilder::new(bob.id(), SmallRng::seed_from_u64(7777)).build()?;
let spawn_note = builder.add_spawn_note([&dummy_note])?;
let mock_chain = builder.build()?;
let mut note_args_map = BTreeMap::new();
note_args_map.insert(pswap_note.id(), PswapNote::create_args(25, 0)?);
let (expected_p2id, _) =
pswap.execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), 25)?), None)?;
let tx_context = mock_chain
.build_tx_context(bob.id(), &[spawn_note.id(), pswap_note.id()], &[])?
.extend_note_args(note_args_map)
.extend_expected_output_notes(vec![
RawOutputNote::Full(dummy_note.clone()),
RawOutputNote::Full(expected_p2id),
])
.build()?;
let executed = tx_context.execute().await?;
let output_notes = executed.output_notes();
assert_eq!(output_notes.num_notes(), 2, "expected dummy + p2id");
let dummy_out = output_notes.get_note(0);
assert_eq!(
dummy_out.assets().num_assets(),
0,
"SPAWN dummy should be empty; non-empty means `create_p2id_note` \
wrote its asset to the wrong output note_idx",
);
let p2id_out = output_notes.get_note(1);
assert_eq!(p2id_out.assets().num_assets(), 1, "P2ID must have 1 asset");
assert_fungible_asset_eq(
p2id_out.assets().iter().next().unwrap(),
FungibleAsset::new(eth_faucet.id(), 25)?,
);
let vault_delta = executed.account_delta().vault();
assert_vault_added_removed(
vault_delta,
FungibleAsset::new(usdc_faucet.id(), 50)?,
FungibleAsset::new(eth_faucet.id(), 25)?,
);
Ok(())
}
#[rstest]
#[case(5)]
#[case(7)]
#[case(10)]
#[case(13)]
#[case(15)]
#[case(19)]
#[case(20)]
#[case(23)]
#[case(25)]
#[tokio::test]
async fn pswap_multiple_partial_fills_test(#[case] fill_amount: u64) -> 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(), fill_amount)?.into()],
)?;
let (pswap, pswap_note) = build_pswap_note(
&mut builder,
alice.id(),
FungibleAsset::new(usdc_faucet.id(), 50)?,
FungibleAsset::new(eth_faucet.id(), 25)?,
NoteType::Public,
)?;
let mock_chain = builder.build()?;
let mut note_args_map = BTreeMap::new();
note_args_map.insert(pswap_note.id(), PswapNote::create_args(fill_amount, 0)?);
let payout_amount = pswap.calculate_offered_for_requested(fill_amount)?;
let (p2id_note, remainder_pswap) =
pswap.execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), fill_amount)?), None)?;
let mut expected_notes = vec![RawOutputNote::Full(p2id_note)];
if let Some(remainder) = remainder_pswap {
expected_notes.push(RawOutputNote::Full(Note::from(remainder)));
}
let tx_context = mock_chain
.build_tx_context(bob.id(), &[pswap_note.id()], &[])?
.extend_expected_output_notes(expected_notes)
.extend_note_args(note_args_map)
.build()?;
let executed_transaction = tx_context.execute().await?;
let output_notes = executed_transaction.output_notes();
let expected_count = if fill_amount < 25 { 2 } else { 1 };
assert_eq!(output_notes.num_notes(), expected_count);
let vault_delta = executed_transaction.account_delta().vault();
assert_vault_single_added(vault_delta, FungibleAsset::new(usdc_faucet.id(), payout_amount)?);
Ok(())
}
async fn run_partial_fill_ratio_case(
offered_usdc: u64,
requested_eth: u64,
fill_eth: u64,
) -> anyhow::Result<()> {
let remaining_requested = requested_eth - fill_eth;
let mut builder = MockChain::builder();
let max_supply = 100_000u64;
let usdc_faucet =
builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", max_supply, Some(offered_usdc))?;
let eth_faucet =
builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", max_supply, Some(fill_eth))?;
let alice = builder.add_existing_wallet_with_assets(
BASIC_AUTH,
[FungibleAsset::new(usdc_faucet.id(), offered_usdc)?.into()],
)?;
let bob = builder.add_existing_wallet_with_assets(
BASIC_AUTH,
[FungibleAsset::new(eth_faucet.id(), fill_eth)?.into()],
)?;
let (pswap, pswap_note) = build_pswap_note(
&mut builder,
alice.id(),
FungibleAsset::new(usdc_faucet.id(), offered_usdc)?,
FungibleAsset::new(eth_faucet.id(), requested_eth)?,
NoteType::Public,
)?;
let mock_chain = builder.build()?;
let mut note_args_map = BTreeMap::new();
note_args_map.insert(pswap_note.id(), PswapNote::create_args(fill_eth, 0)?);
let payout_amount = pswap.calculate_offered_for_requested(fill_eth)?;
let remaining_offered = offered_usdc - payout_amount;
assert!(payout_amount > 0, "payout_amount must be > 0");
assert!(payout_amount <= offered_usdc, "payout_amount > offered");
let (p2id_note, remainder_pswap) =
pswap.execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), fill_eth)?), None)?;
let mut expected_notes = vec![RawOutputNote::Full(p2id_note)];
if remaining_requested > 0 {
let remainder = Note::from(remainder_pswap.expect("partial fill should produce remainder"));
expected_notes.push(RawOutputNote::Full(remainder));
}
let tx_context = mock_chain
.build_tx_context(bob.id(), &[pswap_note.id()], &[])?
.extend_expected_output_notes(expected_notes)
.extend_note_args(note_args_map)
.build()?;
let executed_tx = tx_context.execute().await?;
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);
let vault_delta = executed_tx.account_delta().vault();
assert_vault_added_removed(
vault_delta,
FungibleAsset::new(usdc_faucet.id(), payout_amount)?,
FungibleAsset::new(eth_faucet.id(), fill_eth)?,
);
assert_eq!(payout_amount + remaining_offered, offered_usdc, "conservation");
Ok(())
}
#[rstest]
#[case(100, 30, 7)]
#[case(23, 20, 7)]
#[case(23, 20, 13)]
#[case(23, 20, 19)]
#[case(17, 13, 5)]
#[case(97, 89, 37)]
#[case(53, 47, 23)]
#[case(7, 5, 3)]
#[case(7, 5, 1)]
#[case(7, 5, 4)]
#[case(89, 55, 21)]
#[case(233, 144, 55)]
#[case(34, 21, 8)]
#[case(50, 97, 30)]
#[case(13, 47, 20)]
#[case(3, 7, 5)]
#[case(101, 100, 50)]
#[case(100, 99, 50)]
#[case(997, 991, 500)]
#[case(1000, 3, 1)]
#[case(1000, 3, 2)]
#[case(3, 1000, 500)]
#[case(9999, 7777, 3333)]
#[case(5000, 3333, 1111)]
#[case(127, 63, 31)]
#[case(255, 127, 63)]
#[case(511, 255, 100)]
#[tokio::test]
async fn pswap_partial_fill_ratio_test(
#[case] offered_usdc: u64,
#[case] requested_eth: u64,
#[case] fill_eth: u64,
) -> anyhow::Result<()> {
run_partial_fill_ratio_case(offered_usdc, requested_eth, fill_eth).await
}
#[rstest]
#[case::seed_42(42)]
#[case::seed_1337(1337)]
#[tokio::test]
async fn pswap_partial_fill_ratio_fuzz(#[case] seed: u64) -> anyhow::Result<()> {
use rand::rngs::SmallRng;
use rand::{Rng, SeedableRng};
const FUZZ_ITERATIONS: usize = 30;
let mut rng = SmallRng::seed_from_u64(seed);
for iter in 0..FUZZ_ITERATIONS {
let offered_usdc = rng.random_range(2u64..10_000);
let requested_eth = rng.random_range(2u64..10_000);
let fill_eth = rng.random_range(1u64..=requested_eth);
run_partial_fill_ratio_case(offered_usdc, requested_eth, fill_eth).await.map_err(|e| {
anyhow::anyhow!(
"seed={seed} iter={iter} (offered={offered_usdc}, requested={requested_eth}, fill={fill_eth}): {e}"
)
})?;
}
Ok(())
}
#[rstest]
#[case(100, 73, vec![17, 23, 19])]
#[case(53, 47, vec![7, 11, 13, 5])]
#[case(200, 137, vec![41, 37, 29])]
#[case(7, 5, vec![2, 1])]
#[case(1000, 777, vec![100, 200, 150, 100])]
#[case(50, 97, vec![20, 30, 15])]
#[case(89, 55, vec![13, 8, 21])]
#[case(23, 20, vec![3, 5, 4, 3])]
#[case(997, 991, vec![300, 300, 200])]
#[case(3, 2, vec![1])]
#[tokio::test]
async fn pswap_chained_partial_fills_test(
#[case] initial_offered: u64,
#[case] initial_requested: u64,
#[case] fills: Vec<u64>,
) -> anyhow::Result<()> {
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 rng = RandomCoin::new(Word::default());
let mut current_serial = rng.draw_word();
for (fill_index, fill_amount) in fills.iter().enumerate() {
let remaining_requested = current_requested - fill_amount;
let mut builder = MockChain::builder();
let max_supply = 100_000u64;
let usdc_faucet = builder.add_existing_basic_faucet(
BASIC_AUTH,
"USDC",
max_supply,
Some(current_offered),
)?;
let eth_faucet =
builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", max_supply, Some(*fill_amount))?;
let alice = builder.add_existing_wallet_with_assets(
BASIC_AUTH,
[FungibleAsset::new(usdc_faucet.id(), current_offered)?.into()],
)?;
let bob = builder.add_existing_wallet_with_assets(
BASIC_AUTH,
[FungibleAsset::new(eth_faucet.id(), *fill_amount)?.into()],
)?;
let offered_fungible = FungibleAsset::new(usdc_faucet.id(), current_offered)?;
let requested_fungible = FungibleAsset::new(eth_faucet.id(), current_requested)?;
let storage = PswapNoteStorage::builder()
.requested_asset(requested_fungible)
.creator_account_id(alice.id())
.build();
let pswap = PswapNote::builder()
.sender(alice.id())
.storage(storage)
.serial_number(current_serial)
.note_type(NoteType::Public)
.offered_asset(offered_fungible)
.build()?;
let pswap_note: Note = pswap.clone().into();
builder.add_output_note(RawOutputNote::Full(pswap_note.clone()));
let mock_chain = builder.build()?;
let mut note_args_map = BTreeMap::new();
note_args_map.insert(pswap_note.id(), PswapNote::create_args(*fill_amount, 0)?);
let payout_amount = pswap.calculate_offered_for_requested(*fill_amount)?;
let remaining_offered = current_offered - payout_amount;
let (p2id_note, remainder_pswap) = pswap.execute(
bob.id(),
Some(FungibleAsset::new(eth_faucet.id(), *fill_amount)?),
None,
)?;
let mut expected_notes = vec![RawOutputNote::Full(p2id_note)];
if remaining_requested > 0 {
let remainder =
Note::from(remainder_pswap.expect("partial fill should produce remainder"));
expected_notes.push(RawOutputNote::Full(remainder));
}
let tx_context = mock_chain
.build_tx_context(bob.id(), &[pswap_note.id()], &[])?
.extend_expected_output_notes(expected_notes)
.extend_note_args(note_args_map)
.build()?;
let executed_tx = tx_context.execute().await.map_err(|e| {
anyhow::anyhow!(
"fill {} failed: {} (offered={}, requested={}, fill={})",
fill_index + 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, "fill {}", fill_index + 1);
let vault_delta = executed_tx.account_delta().vault();
assert_vault_single_added(
vault_delta,
FungibleAsset::new(usdc_faucet.id(), payout_amount)?,
);
total_usdc_to_bob += payout_amount;
total_eth_from_bob += fill_amount;
current_offered = remaining_offered;
current_requested = remaining_requested;
current_serial = Word::from([
current_serial[0] + ONE,
current_serial[1],
current_serial[2],
current_serial[3],
]);
}
let total_fills: u64 = fills.iter().sum();
assert_eq!(total_eth_from_bob, total_fills, "ETH conservation");
assert_eq!(total_usdc_to_bob + current_offered, initial_offered, "USDC conservation");
Ok(())
}
#[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();
let mut rng = RandomCoin::new(Word::default());
let requested_asset = FungibleAsset::new(eth_faucet.id(), 25).unwrap();
let storage = PswapNoteStorage::builder()
.requested_asset(requested_asset)
.creator_account_id(alice.id())
.payback_note_type(NoteType::Public)
.build();
let pswap_note: Note = PswapNote::builder()
.sender(alice.id())
.storage(storage)
.serial_number(rng.draw_word())
.note_type(NoteType::Public)
.offered_asset(FungibleAsset::new(usdc_faucet.id(), 50).unwrap())
.build()
.unwrap()
.into();
let pswap = PswapNote::try_from(&pswap_note).unwrap();
assert_eq!(pswap.sender(), alice.id(), "Sender mismatch after roundtrip");
assert_eq!(pswap.note_type(), NoteType::Public, "Note type mismatch after roundtrip");
assert_eq!(pswap.storage().requested_asset_amount(), 25, "Requested amount mismatch");
assert_eq!(pswap.storage().creator_account_id(), alice.id(), "Creator ID mismatch");
let (p2id_note, remainder) = pswap
.execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), 25).unwrap()), None)
.unwrap();
assert!(remainder.is_none(), "Full fill should not produce remainder");
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");
assert_fungible_asset_eq(
p2id_note.assets().iter().next().unwrap(),
FungibleAsset::new(eth_faucet.id(), 25).unwrap(),
);
let (p2id_partial, remainder_partial) = pswap
.execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), 10).unwrap()), None)
.unwrap();
let remainder_pswap = remainder_partial.expect("Partial fill should produce remainder");
assert_eq!(p2id_partial.assets().num_assets(), 1);
assert_fungible_asset_eq(
p2id_partial.assets().iter().next().unwrap(),
FungibleAsset::new(eth_faucet.id(), 10).unwrap(),
);
assert_eq!(
remainder_pswap.storage().creator_account_id(),
alice.id(),
"Remainder creator should be Alice"
);
let remaining_requested = remainder_pswap.storage().requested_asset_amount();
assert_eq!(remaining_requested, 15, "Remaining requested should be 15");
}
#[test]
fn pswap_original_has_no_pswap_scheme() -> 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(50))?;
let alice = builder.add_existing_wallet_with_assets(
BASIC_AUTH,
[FungibleAsset::new(usdc_faucet.id(), 50)?.into()],
)?;
let (pswap, _) = build_pswap_note(
&mut builder,
alice.id(),
FungibleAsset::new(usdc_faucet.id(), 50)?,
FungibleAsset::new(eth_faucet.id(), 25)?,
NoteType::Public,
)?;
if let Some(att) = pswap.attachments() {
assert_ne!(
att.attachment_scheme(),
PswapNote::PSWAP_ATTACHMENT_SCHEME,
"original PSWAP must not carry PswapAttachment — that scheme is reserved for outputs",
);
}
assert_eq!(pswap.parent_depth(), 0, "parent_depth must be 0 for an original PSWAP");
Ok(())
}
#[test]
fn pswap_remainder_carries_pswap_scheme() -> 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(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(), 10)?.into()],
)?;
let (pswap, _) = build_pswap_note(
&mut builder,
alice.id(),
FungibleAsset::new(usdc_faucet.id(), 50)?,
FungibleAsset::new(eth_faucet.id(), 25)?,
NoteType::Public,
)?;
let account_fill = FungibleAsset::new(eth_faucet.id(), 10)?;
let (_, remainder_pswap) = pswap.execute(bob.id(), Some(account_fill), None)?;
let remainder_pswap = remainder_pswap.expect("partial fill should produce a remainder");
let att = remainder_pswap.attachments().expect("remainder must carry an attachment");
assert_eq!(
att.attachment_scheme(),
PswapNote::PSWAP_ATTACHMENT_SCHEME,
"remainder PSWAP must carry PswapAttachment so on-chain depth derivation works",
);
assert_eq!(
remainder_pswap.parent_depth(),
1,
"remainder built from an original PSWAP must carry depth = 1",
);
Ok(())
}
#[tokio::test]
async fn pswap_creator_reconstructs_lineage_from_attachments() -> anyhow::Result<()> {
let fills = [5u64, 8u64, 7u64];
let initial_offered = 50u64;
let initial_requested = 25u64;
let total_fill: u64 = fills.iter().sum();
let mut builder = MockChain::builder();
let max_supply = 100_000u64;
let usdc_faucet =
builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", max_supply, Some(initial_offered))?;
let eth_faucet =
builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", max_supply, Some(total_fill))?;
let alice = builder.add_existing_wallet_with_assets(BASIC_AUTH, [])?;
let bob = builder.add_existing_wallet_with_assets(
BASIC_AUTH,
[FungibleAsset::new(eth_faucet.id(), total_fill)?.into()],
)?;
let original_pswap = PswapNote::builder()
.sender(alice.id())
.storage(
PswapNoteStorage::builder()
.requested_asset(FungibleAsset::new(eth_faucet.id(), initial_requested)?)
.creator_account_id(alice.id())
.build(),
)
.serial_number(RandomCoin::new(Word::default()).draw_word())
.note_type(NoteType::Public)
.offered_asset(FungibleAsset::new(usdc_faucet.id(), initial_offered)?)
.build()?;
let original_pswap_note: Note = original_pswap.clone().into();
builder.add_output_note(RawOutputNote::Full(original_pswap_note.clone()));
let mut mock_chain = builder.build()?;
let mut current_pswap = original_pswap.clone();
let mut current_pswap_note = original_pswap_note;
let mut current_offered = initial_offered;
let mut current_requested = initial_requested;
for (idx, fill_amount) in fills.iter().copied().enumerate() {
let depth = (idx + 1) as u32;
let payout_amount = current_pswap.calculate_offered_for_requested(fill_amount)?;
let remaining_offered = current_offered - payout_amount;
let remaining_requested = current_requested - fill_amount;
let (predicted_payback_note, predicted_remainder_pswap) = current_pswap.execute(
bob.id(),
Some(FungibleAsset::new(eth_faucet.id(), fill_amount)?),
None,
)?;
let mut expected_notes = vec![RawOutputNote::Full(predicted_payback_note.clone())];
let next_pswap_opt = if remaining_requested > 0 {
let predicted_remainder =
predicted_remainder_pswap.expect("partial fill should produce remainder");
expected_notes.push(RawOutputNote::Full(Note::from(predicted_remainder.clone())));
Some(predicted_remainder)
} else {
None
};
let mut note_args_map = BTreeMap::new();
note_args_map.insert(current_pswap_note.id(), PswapNote::create_args(fill_amount, 0)?);
let bob_tx = mock_chain
.build_tx_context(bob.id(), &[current_pswap_note.id()], &[])?
.extend_expected_output_notes(expected_notes)
.extend_note_args(note_args_map)
.build()?
.execute()
.await?;
mock_chain.add_pending_executed_transaction(&bob_tx)?;
mock_chain.prove_next_block()?;
let on_chain_payback = bob_tx.output_notes().get_note(0);
let attachment_word = first_attachment_word(on_chain_payback.attachments());
let fill_from_attachment = attachment_word[0].as_canonical_u64();
assert_eq!(
fill_from_attachment, fill_amount,
"round {depth}: attachment fill amount mismatch",
);
let payback_attachment = PswapNoteAttachment::new(
AssetAmount::new(fill_from_attachment)?,
original_pswap.order_id(),
depth,
);
let reconstructed_payback = original_pswap
.payback_note(on_chain_payback.metadata().sender(), &payback_attachment)?;
assert_eq!(
reconstructed_payback.details_commitment(),
on_chain_payback.details_commitment(),
"round {depth}: reconstructed payback commitment must match on-chain leaf",
);
if next_pswap_opt.is_some() {
let on_chain_remainder = bob_tx.output_notes().get_note(1);
let remainder_attachment_word = first_attachment_word(on_chain_remainder.attachments());
let payout_from_attachment = remainder_attachment_word[0].as_canonical_u64();
let remainder_attachment = PswapNoteAttachment::new(
AssetAmount::new(payout_from_attachment)?,
original_pswap.order_id(),
depth,
);
let reconstructed_remainder = original_pswap.remainder_note(
on_chain_remainder.metadata().sender(),
&remainder_attachment,
AssetAmount::new(remaining_offered)?,
AssetAmount::new(remaining_requested)?,
)?;
assert_eq!(
reconstructed_remainder.details_commitment(),
on_chain_remainder.details_commitment(),
"round {depth}: reconstructed remainder commitment must match on-chain leaf",
);
}
let alice_tx = mock_chain
.build_tx_context(alice.id(), &[], slice::from_ref(&reconstructed_payback))?
.build()?
.execute()
.await?;
assert_vault_single_added(
alice_tx.account_delta().vault(),
FungibleAsset::new(eth_faucet.id(), fill_amount)?,
);
mock_chain.add_pending_executed_transaction(&alice_tx)?;
mock_chain.prove_next_block()?;
if let Some(next) = next_pswap_opt {
current_pswap_note = Note::from(next.clone());
current_pswap = next;
current_offered = remaining_offered;
current_requested = remaining_requested;
}
}
Ok(())
}
#[tokio::test]
async fn pswap_disambiguates_multiple_creator_pswaps_in_same_tx() -> anyhow::Result<()> {
let mut builder = MockChain::builder();
let usdc_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "USDC", 1_000, Some(100))?;
let eth_faucet = builder.add_existing_basic_faucet(BASIC_AUTH, "ETH", 1_000, Some(50))?;
let alice = builder.add_existing_wallet_with_assets(
BASIC_AUTH,
[FungibleAsset::new(usdc_faucet.id(), 100)?.into()],
)?;
let bob = builder.add_existing_wallet_with_assets(
BASIC_AUTH,
[FungibleAsset::new(eth_faucet.id(), 30)?.into()],
)?;
let pswap_a = {
let mut rng = RandomCoin::new(Word::default());
let serial = rng.draw_word();
let storage = PswapNoteStorage::builder()
.requested_asset(FungibleAsset::new(eth_faucet.id(), 20)?)
.creator_account_id(alice.id())
.build();
PswapNote::builder()
.sender(alice.id())
.storage(storage)
.serial_number(serial)
.note_type(NoteType::Public)
.offered_asset(FungibleAsset::new(usdc_faucet.id(), 40)?)
.build()?
};
let pswap_b = {
let mut rng = RandomCoin::new(Word::from([Felt::from(7u32); 4]));
let serial = rng.draw_word();
let storage = PswapNoteStorage::builder()
.requested_asset(FungibleAsset::new(eth_faucet.id(), 30)?)
.creator_account_id(alice.id())
.build();
PswapNote::builder()
.sender(alice.id())
.storage(storage)
.serial_number(serial)
.note_type(NoteType::Public)
.offered_asset(FungibleAsset::new(usdc_faucet.id(), 60)?)
.build()?
};
assert_ne!(pswap_a.order_id(), pswap_b.order_id(), "test setup: order_ids must differ");
let note_a: Note = pswap_a.clone().into();
let note_b: Note = pswap_b.clone().into();
builder.add_output_note(RawOutputNote::Full(note_a.clone()));
builder.add_output_note(RawOutputNote::Full(note_b.clone()));
let mock_chain = builder.build()?;
let fill_each = 10u64;
let mut note_args = BTreeMap::new();
note_args.insert(note_a.id(), PswapNote::create_args(fill_each, 0)?);
note_args.insert(note_b.id(), PswapNote::create_args(fill_each, 0)?);
let (payback_a, remainder_a) =
pswap_a.execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), fill_each)?), None)?;
let (payback_b, remainder_b) =
pswap_b.execute(bob.id(), Some(FungibleAsset::new(eth_faucet.id(), fill_each)?), None)?;
let remainder_a_note = Note::from(remainder_a.expect("partial fill A produces remainder"));
let remainder_b_note = Note::from(remainder_b.expect("partial fill B produces remainder"));
let tx_context = mock_chain
.build_tx_context(bob.id(), &[note_a.id(), note_b.id()], &[])?
.extend_note_args(note_args)
.extend_expected_output_notes(vec![
RawOutputNote::Full(payback_a.clone()),
RawOutputNote::Full(remainder_a_note.clone()),
RawOutputNote::Full(payback_b.clone()),
RawOutputNote::Full(remainder_b_note.clone()),
])
.build()?;
let executed_tx = tx_context.execute().await?;
let outputs = executed_tx.output_notes();
assert_eq!(outputs.num_notes(), 4, "expected 2 paybacks + 2 remainders in same tx");
let order_id_a = pswap_a.order_id();
let order_id_b = pswap_b.order_id();
let mut from_a: Vec<Word> = Vec::with_capacity(2);
let mut from_b: Vec<Word> = Vec::with_capacity(2);
const ORDER_ID_INDEX_IN_PSWAP_ATTACHMENT: usize = 1;
for i in 0..outputs.num_notes() {
let att_word = first_attachment_word(outputs.get_note(i).attachments());
let oid = att_word[ORDER_ID_INDEX_IN_PSWAP_ATTACHMENT];
let digest = outputs.get_note(i).recipient_digest();
if oid == order_id_a {
from_a.push(digest);
} else if oid == order_id_b {
from_b.push(digest);
} else {
panic!("output note's order_id matches neither lineage");
}
}
assert_eq!(from_a.len(), 2, "lineage A should yield 2 notes (payback + remainder)");
assert_eq!(from_b.len(), 2, "lineage B should yield 2 notes (payback + remainder)");
assert!(
from_a.contains(&payback_a.recipient().digest())
&& from_a.contains(&remainder_a_note.recipient().digest()),
"lineage A's notes must include both Rust-predicted output digests",
);
assert!(
from_b.contains(&payback_b.recipient().digest())
&& from_b.contains(&remainder_b_note.recipient().digest()),
"lineage B's notes must include both Rust-predicted output digests",
);
Ok(())
}
#[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 (_, pswap_note) = build_pswap_note(
&mut builder,
alice.id(),
FungibleAsset::new(usdc_faucet.id(), 50).unwrap(),
FungibleAsset::new(eth_faucet.id(), 25).unwrap(),
NoteType::Public,
)
.unwrap();
let storage = pswap_note.recipient().storage();
let items = storage.items();
let parsed = PswapNoteStorage::try_from(items).unwrap();
assert_eq!(parsed.creator_account_id(), alice.id(), "Creator ID roundtrip failed!");
assert_eq!(parsed.requested_asset_amount(), 25, "Requested amount should be 25");
}