diff --git a/CHANGELOG.md b/CHANGELOG.md index 5da69c204..4ddad0e57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Enhancements + +* [FEATURE][cli] Added a `--payback-note-type` option to `swap` so the payback note can be created as public or private (defaults to private). Public payback works without any off-band advice now that SWAP derives the payback recipient deterministically ([#2190](https://github.com/0xMiden/rust-sdk/pull/2190)). + ## 0.16.0-alpha.1 (2026-07-17) ### Breaking Changes diff --git a/bin/integration-tests/src/tests/swap_transaction.rs b/bin/integration-tests/src/tests/swap_transaction.rs index 6d75c759f..664650a1e 100644 --- a/bin/integration-tests/src/tests/swap_transaction.rs +++ b/bin/integration-tests/src/tests/swap_transaction.rs @@ -166,6 +166,145 @@ pub async fn test_swap_fully_onchain(client_config: ClientConfig) -> Result<()> Ok(()) } +/// Same shape as `test_swap_fully_onchain` but with a public payback note. Exercises the +/// deterministic payback-recipient derivation: the consumer materializes the public payback +/// without any off-band advice, the node indexes it, and the original sender retrieves and +/// consumes it after a regular sync. +pub async fn test_swap_public_payback(client_config: ClientConfig) -> Result<()> { + const OFFERED_ASSET_AMOUNT: u64 = 1; + const REQUESTED_ASSET_AMOUNT: u64 = 25; + let (mut client1, authenticator_1) = client_config.clone().into_client().await?; + wait_for_node(&mut client1).await; + let (mut client2, authenticator_2) = ClientConfig::default() + .with_rpc_endpoint(client_config.rpc_endpoint()) + .into_client() + .await?; + + client1.sync_state().await?; + client2.sync_state().await?; + + let (account_a, ..) = insert_new_wallet( + &mut client1, + AccountType::Private, + &authenticator_1, + RPO_FALCON_SCHEME_ID, + ) + .await?; + let (account_b, ..) = insert_new_wallet( + &mut client2, + AccountType::Private, + &authenticator_2, + RPO_FALCON_SCHEME_ID, + ) + .await?; + + let (btc_faucet_account, _) = insert_new_fungible_faucet( + &mut client1, + AccountType::Private, + &authenticator_1, + RPO_FALCON_SCHEME_ID, + ) + .await?; + let (eth_faucet_account, _) = insert_new_fungible_faucet( + &mut client2, + AccountType::Private, + &authenticator_2, + RPO_FALCON_SCHEME_ID, + ) + .await?; + + info!(account_id = %account_a.id(), faucet_id = %btc_faucet_account.id(), "Minting 1000 BTC for account A"); + let tx_id = + mint_and_consume(&mut client1, account_a.id(), btc_faucet_account.id(), NoteType::Public) + .await; + wait_for_tx(&mut client1, tx_id).await?; + + info!(account_id = %account_b.id(), faucet_id = %eth_faucet_account.id(), "Minting 1000 ETH for account B"); + let tx_id = + mint_and_consume(&mut client2, account_b.id(), eth_faucet_account.id(), NoteType::Public) + .await; + wait_for_tx(&mut client2, tx_id).await?; + + let offered_asset = FungibleAsset::new(btc_faucet_account.id(), OFFERED_ASSET_AMOUNT)?; + let requested_asset = FungibleAsset::new(eth_faucet_account.id(), REQUESTED_ASSET_AMOUNT)?; + info!(account_id = %account_a.id(), "Creating swap note with public payback"); + + let tx_request = TransactionRequestBuilder::new().build_swap( + &SwapTransactionData::new( + account_a.id(), + Asset::Fungible(offered_asset), + Asset::Fungible(requested_asset), + ), + NoteType::Public, + NoteType::Public, + client1.rng(), + )?; + + let expected_output_notes: Vec = tx_request.expected_output_own_notes(); + let expected_payback_note_details: Vec = + tx_request.expected_future_notes().cloned().map(|(n, _)| n).collect(); + assert_eq!(expected_output_notes.len(), 1); + assert_eq!(expected_payback_note_details.len(), 1); + + execute_tx_and_sync(&mut client1, account_a.id(), tx_request).await?; + + let swap_note_tag = SwapNote::build_tag( + NoteType::Public, + &Asset::Fungible(offered_asset), + &Asset::Fungible(requested_asset), + ); + info!(tag = %swap_note_tag, "Adding swap note tag to client 2"); + client2.add_note_tag(swap_note_tag).await?; + + client2.sync_state().await?; + info!(note_id = %expected_output_notes[0].id(), account_id = %account_b.id(), "Consuming swap note on client 2"); + + let note = client2 + .get_input_note(expected_output_notes[0].id()) + .await? + .unwrap() + .try_into()?; + let tx_request = TransactionRequestBuilder::new().build_consume_notes(vec![note])?; + execute_tx_and_sync(&mut client2, account_b.id(), tx_request).await?; + + client1.sync_state().await?; + let payback_commitment = expected_payback_note_details[0].commitment(); + info!(note = %payback_commitment.to_hex(), account_id = %account_a.id(), "Consuming public payback note on client 1"); + + let payback_record = client1 + .get_input_notes(NoteFilter::DetailsCommitments(vec![payback_commitment])) + .await? + .pop() + .with_context(|| format!("Payback note {} not found", payback_commitment.to_hex()))?; + + // The node must have committed the payback as a public note. + assert_eq!( + payback_record + .metadata() + .context("payback note should have metadata")? + .note_type(), + NoteType::Public + ); + + let note: Note = payback_record.try_into()?; + let tx_request = TransactionRequestBuilder::new().build_consume_notes(vec![note])?; + execute_tx_and_sync(&mut client1, account_a.id(), tx_request).await?; + + let account_a_reader = client1.account_reader(account_a.id()); + let account_a_btc = account_a_reader.get_balance(btc_faucet_account.id()).await?; + let account_a_eth = account_a_reader.get_balance(eth_faucet_account.id()).await?; + assert_eq!(account_a_btc, AssetAmount::new(999).unwrap()); + assert_eq!(account_a_eth, AssetAmount::new(25).unwrap()); + + let account_b_reader = client2.account_reader(account_b.id()); + let account_b_btc = account_b_reader.get_balance(btc_faucet_account.id()).await?; + let account_b_eth = account_b_reader.get_balance(eth_faucet_account.id()).await?; + assert_eq!(account_b_btc, AssetAmount::new(1).unwrap()); + assert_eq!(account_b_eth, AssetAmount::new(975).unwrap()); + + Ok(()) +} + pub async fn test_swap_private(client_config: ClientConfig) -> Result<()> { const OFFERED_ASSET_AMOUNT: u64 = 1; const REQUESTED_ASSET_AMOUNT: u64 = 25; diff --git a/bin/miden-cli/src/commands/new_transactions.rs b/bin/miden-cli/src/commands/new_transactions.rs index b12f3af48..d03290c6f 100644 --- a/bin/miden-cli/src/commands/new_transactions.rs +++ b/bin/miden-cli/src/commands/new_transactions.rs @@ -8,8 +8,8 @@ use miden_client::keystore::Keystore; use miden_client::note::{ BlockNumber, Note, + NoteTag, NoteType as MidenNoteType, - SwapNote, get_input_note_with_id_prefix, }; use miden_client::store::NoteRecordError; @@ -211,6 +211,11 @@ pub struct SwapCmd { #[arg(short, long, value_enum)] note_type: NoteType, + /// Visibility of the payback note produced when the swap is consumed. Defaults to private + /// (cheaper, and the swap already records the trade in the consuming transaction). + #[arg(long, value_enum, default_value_t = NoteType::Private)] + payback_note_type: NoteType, + /// Flag to submit the executed transaction without asking for confirmation. #[arg(long, default_value_t = false)] force: bool, @@ -248,7 +253,7 @@ impl SwapCmd { .build_swap( &swap_transaction, (&self.note_type).into(), - MidenNoteType::Private, + (&self.payback_note_type).into(), client.rng(), ) .map_err(|err| { @@ -264,14 +269,9 @@ impl SwapCmd { ) .await?; - let payback_note_tag: u32 = SwapNote::build_tag( - (&self.note_type).into(), - &swap_transaction.offered_asset(), - &swap_transaction.requested_asset(), - ) - .into(); + let payback_note_tag: u32 = NoteTag::with_account_target(sender_account_id).into(); println!( - "To receive updates about the payback Swap Note run `miden-client tags --add {payback_note_tag}`", + "To receive updates about the payback note run `miden-client tags --add {payback_note_tag}`", ); Ok(()) diff --git a/crates/testing/miden-client-tests/src/tests.rs b/crates/testing/miden-client-tests/src/tests.rs index 4ea863c79..9abaf3728 100644 --- a/crates/testing/miden-client-tests/src/tests.rs +++ b/crates/testing/miden-client-tests/src/tests.rs @@ -2698,6 +2698,72 @@ async fn swap_chain_test() { assert_eq!(last_wallet_balance, AssetAmount::new(1).unwrap()); } +#[tokio::test] +async fn swap_public_payback_test() { + let (mut client, mock_rpc_api, keystore) = create_test_client().await; + + let (wallet_a, faucet_a) = + setup_wallet_and_faucet(&mut client, AccountType::Private, &keystore, RPO_FALCON_SCHEME_ID) + .await + .unwrap(); + mint_and_consume(&mut client, wallet_a.id(), faucet_a.id(), NoteType::Private).await; + mock_rpc_api.prove_block(); + client.sync_state().await.unwrap(); + + let (wallet_b, faucet_b) = + setup_wallet_and_faucet(&mut client, AccountType::Private, &keystore, RPO_FALCON_SCHEME_ID) + .await + .unwrap(); + mint_and_consume(&mut client, wallet_b.id(), faucet_b.id(), NoteType::Private).await; + mock_rpc_api.prove_block(); + client.sync_state().await.unwrap(); + + // wallet_a offers asset_a and requests asset_b, with PUBLIC payback. + let tx_request = TransactionRequestBuilder::new() + .build_swap( + &SwapTransactionData::new( + wallet_a.id(), + Asset::Fungible(FungibleAsset::new(faucet_a.id(), 1).unwrap()), + Asset::Fungible(FungibleAsset::new(faucet_b.id(), 1).unwrap()), + ), + NoteType::Private, + NoteType::Public, + client.rng(), + ) + .unwrap(); + + let swap_note = tx_request.expected_output_own_notes()[0].clone(); + let payback_commitment = tx_request.expected_future_notes().next().unwrap().0.commitment(); + Box::pin(client.submit_new_transaction(wallet_a.id(), tx_request)) + .await + .unwrap(); + mock_rpc_api.prove_block(); + client.sync_state().await.unwrap(); + + // wallet_b consumes the swap, producing a public P2ID payback to wallet_a with no off-band + // data. + let tx_request = TransactionRequestBuilder::new().build_consume_notes(vec![swap_note]).unwrap(); + Box::pin(client.submit_new_transaction(wallet_b.id(), tx_request)) + .await + .unwrap(); + mock_rpc_api.prove_block(); + client.sync_state().await.unwrap(); + + // The payback note's committed metadata must show it was emitted as a public note. + let payback_record = client + .get_input_notes(NoteFilter::DetailsCommitments(vec![payback_commitment])) + .await + .unwrap() + .pop() + .unwrap(); + assert_eq!(payback_record.metadata().unwrap().note_type(), NoteType::Public); + + // wallet_b ended up with asset_a, wallet_a will receive asset_b via the public payback note. + let wallet_b_balance = + client.account_reader(wallet_b.id()).get_balance(faucet_a.id()).await.unwrap(); + assert_eq!(wallet_b_balance, AssetAmount::new(1).unwrap()); +} + /// Tests that partial output notes (created when a SWAP note is consumed) are correctly included in /// `NoteFilter::Unspent` and receive inclusion proofs during sync, transitioning from /// `ExpectedPartial` to `CommittedPartial` state. @@ -2751,9 +2817,10 @@ async fn partial_output_note_receives_inclusion_proof_after_sync() { mock_rpc_api.prove_block(); client.sync_state().await.unwrap(); - // Wallet B consumes the SWAP note. The SWAP script creates a payback note for wallet A using - // only the recipient digest committed inside the SWAP note. From the VM's perspective this - // payback note is an OutputNote::Partial, which gets stored as ExpectedPartial. + // Wallet B consumes the SWAP note. The SWAP script derives the payback recipient at consume + // time (P2ID to the creator with serial = SWAP_serial with element 0 + 1) and emits the + // payback note. From the VM's perspective this payback note is an OutputNote::Partial, which + // gets stored as ExpectedPartial. let consume_tx_request = TransactionRequestBuilder::new().build_consume_notes(vec![swap_note]).unwrap(); diff --git a/docs/external/src/rust-client/cli/index.md b/docs/external/src/rust-client/cli/index.md index 277be2dad..29da31343 100644 --- a/docs/external/src/rust-client/cli/index.md +++ b/docs/external/src/rust-client/cli/index.md @@ -302,7 +302,9 @@ Usage: `miden-client transfer --sender --target --offered-asset :: --requested-asset :: --note-type ` +Usage: `miden-client swap --source --offered-asset :: --requested-asset :: --note-type [--payback-note-type ]` + +The `--payback-note-type` option controls the visibility of the payback note created when the swap is consumed. It defaults to `private`. ### `address`