Parcourir la source

Swap before melt (#1373)

* feat(cdk): swap proofs before melt to get exact denominations

When melting, proofs may not match the optimal denomination split needed
for the melt amount. This adds pre-melt swapping to convert proofs into
the exact denominations required, reducing fees and avoiding overpayment.

Uses the existing split_proofs_for_send function to determine which
proofs can be used directly and which need swapping.
tsk il y a 3 mois
Parent
commit
ffcefff53d

+ 1 - 1
crates/cdk-integration-tests/src/init_pure_tests.rs

@@ -258,7 +258,7 @@ pub async fn create_and_start_test_mint() -> Result<Mint> {
 
     let fee_reserve = FeeReserve {
         min_fee_reserve: 1.into(),
-        percent_fee_reserve: 1.0,
+        percent_fee_reserve: 0.02,
     };
 
     let ln_fake_backend = FakeWallet::new(

+ 127 - 0
crates/cdk-integration-tests/tests/fake_wallet.rs

@@ -1734,3 +1734,130 @@ async fn test_melt_proofs_external() {
     assert_eq!(transactions.len(), 1);
     assert_eq!(transactions[0].amount, Amount::from(9));
 }
+
+/// Tests that melt automatically performs a swap when proofs don't exactly match
+/// the required amount (quote + fee_reserve + input_fee).
+///
+/// This test verifies the swap-before-melt optimization:
+/// 1. Mint proofs that will NOT exactly match a melt amount
+/// 2. Create a melt quote for a specific amount
+/// 3. Call melt() - it should automatically swap proofs to get exact denominations
+/// 4. Verify the melt succeeded
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_melt_with_swap_for_exact_amount() {
+    let wallet = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create new wallet");
+
+    // Mint 100 sats - this will give us proofs in standard denominations
+    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+
+    let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
+
+    let proofs = proof_streams
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    let initial_balance = wallet.total_balance().await.unwrap();
+    assert_eq!(initial_balance, Amount::from(100));
+
+    // Log the proof denominations we received
+    let proof_amounts: Vec<u64> = proofs.iter().map(|p| u64::from(p.amount)).collect();
+    tracing::info!("Initial proof denominations: {:?}", proof_amounts);
+
+    // Create a melt quote for an amount that likely won't match our proof denominations exactly
+    // Using 7 sats (7000 msats) which requires specific denominations
+    let fake_description = FakeInvoiceDescription::default();
+    let invoice = create_fake_invoice(7000, serde_json::to_string(&fake_description).unwrap());
+
+    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+
+    tracing::info!(
+        "Melt quote: amount={}, fee_reserve={}",
+        melt_quote.amount,
+        melt_quote.fee_reserve
+    );
+
+    // Call melt() - this should trigger swap-before-melt if proofs don't match exactly
+    let melted = wallet.melt(&melt_quote.id).await.unwrap();
+
+    // Verify the melt succeeded
+    assert_eq!(melted.amount, Amount::from(7));
+
+    tracing::info!(
+        "Melt completed: amount={}, fee_paid={}",
+        melted.amount,
+        melted.fee_paid
+    );
+
+    // Verify final balance is correct (initial - melt_amount - fees)
+    let final_balance = wallet.total_balance().await.unwrap();
+    tracing::info!(
+        "Balance: initial={}, final={}, paid={}",
+        initial_balance,
+        final_balance,
+        melted.amount + melted.fee_paid
+    );
+
+    assert!(
+        final_balance < initial_balance,
+        "Balance should have decreased after melt"
+    );
+    assert_eq!(
+        final_balance,
+        initial_balance - melted.amount - melted.fee_paid,
+        "Final balance should be initial - amount - fees"
+    );
+}
+
+/// Tests that melt works correctly when proofs already exactly match the required amount.
+/// In this case, no swap should be needed.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_melt_exact_proofs_no_swap_needed() {
+    let wallet = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create new wallet");
+
+    // Mint a larger amount to have more denomination options
+    let mint_quote = wallet.mint_quote(1000.into(), None).await.unwrap();
+
+    let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
+
+    let _proofs = proof_streams
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    let initial_balance = wallet.total_balance().await.unwrap();
+    assert_eq!(initial_balance, Amount::from(1000));
+
+    // Create a melt for a power-of-2 amount that's more likely to match existing denominations
+    let fake_description = FakeInvoiceDescription::default();
+    let invoice = create_fake_invoice(64_000, serde_json::to_string(&fake_description).unwrap()); // 64 sats
+
+    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+
+    // Melt should succeed
+    let melted = wallet.melt(&melt_quote.id).await.unwrap();
+
+    assert_eq!(melted.amount, Amount::from(64));
+
+    let final_balance = wallet.total_balance().await.unwrap();
+    assert_eq!(
+        final_balance,
+        initial_balance - melted.amount - melted.fee_paid
+    );
+}

+ 437 - 1
crates/cdk-integration-tests/tests/test_swap_flow.rs

@@ -18,6 +18,7 @@ use cashu::{CurrencyUnit, Id, PreMintSecrets, SecretKey, SpendingConditions, Sta
 use cdk::mint::Mint;
 use cdk::nuts::nut00::ProofsMethods;
 use cdk::Amount;
+use cdk_fake_wallet::create_fake_invoice;
 use cdk_integration_tests::init_pure_tests::*;
 
 /// Helper to get the active keyset ID from a mint
@@ -660,7 +661,7 @@ async fn test_swap_with_fees() {
     mint.rotate_keyset(
         CurrencyUnit::Sat,
         cdk_integration_tests::standard_keyset_amounts(32),
-        1,
+        100,
     )
     .await
     .expect("Failed to rotate keyset");
@@ -727,6 +728,441 @@ async fn test_swap_with_fees() {
     }
 }
 
+/// Tests melt with fees enabled and swap-before-melt optimization:
+/// 1. Create mint with keyset that has fees (1000 ppk = 1 sat per proof)
+/// 2. Fund wallet with proofs using default split (optimal denominations)
+/// 3. Call melt() - should automatically swap if proofs don't match exactly
+/// 4. Verify fee calculations are reasonable
+///
+/// Fee calculation:
+/// - Initial: 4096 sats in optimal denominations
+/// - Melt: 1000 sats, fee_reserve = 20 sats (2%)
+/// - inputs_needed = 1020 sats
+/// - Target split for 1020: [512, 256, 128, 64, 32, 16, 8, 4] = 8 proofs
+/// - target_fee = 8 sats
+/// - inputs_total_needed = 1028 sats
+///
+/// The wallet uses two-step selection:
+/// - Step 1: Try to find exact proofs for inputs_needed (no swap fee)
+/// - Step 2: If not exact, select proofs for inputs_total_needed and swap
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_melt_with_fees_swap_before_melt() {
+    setup_tracing();
+    let mint = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+
+    let wallet = create_test_wallet_for_mint(mint.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Rotate to keyset with 1000 ppk = 1 sat per proof fee
+    mint.rotate_keyset(
+        CurrencyUnit::Sat,
+        cdk_integration_tests::standard_keyset_amounts(32),
+        1000, // 1 sat per proof
+    )
+    .await
+    .expect("Failed to rotate keyset");
+
+    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+
+    // Fund with default split target to get optimal denominations
+    // Use larger amount to ensure enough margin for swap fees
+    let initial_amount = 4096u64;
+    fund_wallet(wallet.clone(), initial_amount, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let initial_balance: u64 = wallet.total_balance().await.unwrap().into();
+    assert_eq!(initial_balance, initial_amount);
+
+    let proofs = wallet.get_unspent_proofs().await.unwrap();
+    let proof_amounts: Vec<u64> = proofs.iter().map(|p| u64::from(p.amount)).collect();
+    tracing::info!("Proofs after funding: {:?}", proof_amounts);
+
+    let proofs_total: u64 = proof_amounts.iter().sum();
+    assert_eq!(
+        proofs_total, initial_amount,
+        "Total proofs should equal funded amount"
+    );
+
+    // Create melt quote for 1000 sats (1_000_000 msats)
+    // Fake wallet: fee_reserve = max(1, amount * 2%) = 20 sats
+    let invoice = create_fake_invoice(1_000_000, "".to_string()); // 1000 sats in msats
+    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+
+    let quote_amount: u64 = melt_quote.amount.into();
+    let fee_reserve: u64 = melt_quote.fee_reserve.into();
+
+    tracing::info!(
+        "Melt quote: amount={}, fee_reserve={}",
+        quote_amount,
+        fee_reserve
+    );
+
+    let initial_proof_count = proofs.len();
+
+    tracing::info!(
+        "Initial state: {} proofs, {} sats",
+        initial_proof_count,
+        proofs_total
+    );
+
+    // Perform melt
+    let melted = wallet.melt(&melt_quote.id).await.unwrap();
+
+    let melt_amount: u64 = melted.amount.into();
+    let ln_fee_paid: u64 = melted.fee_paid.into();
+
+    tracing::info!(
+        "Melt completed: amount={}, ln_fee_paid={}",
+        melt_amount,
+        ln_fee_paid
+    );
+
+    assert_eq!(melt_amount, quote_amount, "Melt amount should match quote");
+
+    // Get final balance and calculate fees
+    let final_balance: u64 = wallet.total_balance().await.unwrap().into();
+    let total_spent = initial_amount - final_balance;
+    let total_fees = total_spent - melt_amount;
+
+    tracing::info!(
+        "Balance: initial={}, final={}, total_spent={}, melt_amount={}, total_fees={}",
+        initial_amount,
+        final_balance,
+        total_spent,
+        melt_amount,
+        total_fees
+    );
+
+    // Calculate input fees (swap + melt)
+    let input_fees = total_fees - ln_fee_paid;
+
+    tracing::info!(
+        "Fee breakdown: total_fees={}, ln_fee={}, input_fees (swap+melt)={}",
+        total_fees,
+        ln_fee_paid,
+        input_fees
+    );
+
+    // Verify input fees are reasonable
+    // With swap-before-melt optimization, we use fewer proofs for the melt
+    // Melt uses ~8 proofs for optimal split of 1028, so input_fee ~= 8
+    // Swap (if any) also has fees, but the optimization minimizes total fees
+    assert!(
+        input_fees > 0,
+        "Should have some input fees with fee-enabled keyset"
+    );
+    assert!(
+        input_fees <= 20,
+        "Input fees {} should be reasonable (not too high)",
+        input_fees
+    );
+
+    // Verify we have change remaining
+    assert!(final_balance > 0, "Should have change remaining after melt");
+
+    tracing::info!(
+        "Test passed: spent {} sats, fees {} (ln={}, input={}), remaining {}",
+        total_spent,
+        total_fees,
+        ln_fee_paid,
+        input_fees,
+        final_balance
+    );
+}
+
+/// Tests the "exact match" early return path in melt_with_metadata.
+/// When proofs already exactly match inputs_needed_amount, no swap is required.
+///
+/// This tests Step 1 of the two-step selection:
+/// - Select proofs for inputs_needed_amount
+/// - If exact match, use proofs directly without swap
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_melt_exact_match_no_swap() {
+    setup_tracing();
+    let mint = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+
+    let wallet = create_test_wallet_for_mint(mint.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Use keyset WITHOUT fees to make exact match easier
+    // (default keyset has no fees)
+
+    // Fund with exactly inputs_needed_amount to trigger the exact match path
+    // For a 1000 sat melt, fee_reserve = max(1, 1000 * 2%) = 20 sats
+    // inputs_needed = 1000 + 20 = 1020 sats
+    let initial_amount = 1020u64;
+    fund_wallet(wallet.clone(), initial_amount, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let initial_balance: u64 = wallet.total_balance().await.unwrap().into();
+    assert_eq!(initial_balance, initial_amount);
+
+    let proofs_before = wallet.get_unspent_proofs().await.unwrap();
+    tracing::info!(
+        "Proofs before melt: {:?}",
+        proofs_before
+            .iter()
+            .map(|p| u64::from(p.amount))
+            .collect::<Vec<_>>()
+    );
+
+    // Create melt quote for 1000 sats
+    // fee_reserve = max(1, 1000 * 2%) = 20 sats
+    // inputs_needed = 1000 + 20 = 1020 sats = our exact balance
+    let invoice = create_fake_invoice(1_000_000, "".to_string());
+    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+
+    let quote_amount: u64 = melt_quote.amount.into();
+    let fee_reserve: u64 = melt_quote.fee_reserve.into();
+    let inputs_needed = quote_amount + fee_reserve;
+
+    tracing::info!(
+        "Melt quote: amount={}, fee_reserve={}, inputs_needed={}",
+        quote_amount,
+        fee_reserve,
+        inputs_needed
+    );
+
+    // Perform melt
+    let melted = wallet.melt(&melt_quote.id).await.unwrap();
+
+    let melt_amount: u64 = melted.amount.into();
+    let ln_fee_paid: u64 = melted.fee_paid.into();
+
+    tracing::info!(
+        "Melt completed: amount={}, ln_fee_paid={}",
+        melt_amount,
+        ln_fee_paid
+    );
+
+    assert_eq!(melt_amount, quote_amount, "Melt amount should match quote");
+
+    // Get final balance
+    let final_balance: u64 = wallet.total_balance().await.unwrap().into();
+    let total_spent = initial_amount - final_balance;
+    let total_fees = total_spent - melt_amount;
+
+    tracing::info!(
+        "Balance: initial={}, final={}, total_spent={}, total_fees={}",
+        initial_amount,
+        final_balance,
+        total_spent,
+        total_fees
+    );
+
+    // With no keyset fees and no swap needed, total fees should just be ln_fee
+    // (no input fees since default keyset has 0 ppk)
+    assert_eq!(
+        total_fees, ln_fee_paid,
+        "Total fees should equal LN fee (no swap or input fees with 0 ppk keyset)"
+    );
+
+    tracing::info!("Test passed: exact match path used, no swap needed");
+}
+
+/// Tests melt with small amounts where swap margin is too tight.
+/// When fees are high relative to the melt amount, the swap-before-melt
+/// optimization may not have enough margin to cover both input and output fees.
+/// In this case, the wallet should fall back to using proofs directly.
+///
+/// Scenario:
+/// - Fund with 8 sats
+/// - Melt 5 sats (with 2% fee_reserve = 1 sat min, so inputs_needed = 6)
+/// - With 1 sat per proof fee, the swap margin becomes too tight
+/// - Should still succeed by falling back to direct melt
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_melt_small_amount_tight_margin() {
+    setup_tracing();
+    let mint = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+
+    let wallet = create_test_wallet_for_mint(mint.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Rotate to keyset with 1000 ppk = 1 sat per proof fee
+    mint.rotate_keyset(
+        CurrencyUnit::Sat,
+        cdk_integration_tests::standard_keyset_amounts(32),
+        1000,
+    )
+    .await
+    .expect("Failed to rotate keyset");
+
+    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+
+    // Fund with enough to cover melt + fees, but amounts that will trigger swap
+    // 32 sats gives us enough margin even with 1 sat/proof fees
+    let initial_amount = 32;
+    fund_wallet(wallet.clone(), initial_amount, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let initial_balance: u64 = wallet.total_balance().await.unwrap().into();
+    assert_eq!(initial_balance, initial_amount);
+
+    let proofs = wallet.get_unspent_proofs().await.unwrap();
+    tracing::info!(
+        "Proofs after funding: {:?}",
+        proofs
+            .iter()
+            .map(|p| u64::from(p.amount))
+            .collect::<Vec<_>>()
+    );
+
+    // Create melt quote for 5 sats
+    // fee_reserve = max(1, 5 * 2%) = 1 sat
+    // inputs_needed = 5 + 1 = 6 sats
+    let invoice = create_fake_invoice(5_000, "".to_string()); // 5 sats in msats
+    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+
+    let quote_amount: u64 = melt_quote.amount.into();
+    let fee_reserve: u64 = melt_quote.fee_reserve.into();
+
+    tracing::info!(
+        "Melt quote: amount={}, fee_reserve={}, inputs_needed={}",
+        quote_amount,
+        fee_reserve,
+        quote_amount + fee_reserve
+    );
+
+    // This should succeed even with tight margins
+    let melted = wallet
+        .melt(&melt_quote.id)
+        .await
+        .expect("Melt should succeed even with tight swap margin");
+
+    let melt_amount: u64 = melted.amount.into();
+    assert_eq!(melt_amount, quote_amount, "Melt amount should match quote");
+
+    let final_balance: u64 = wallet.total_balance().await.unwrap().into();
+    tracing::info!(
+        "Melt completed: amount={}, fee_paid={}, final_balance={}",
+        melted.amount,
+        melted.fee_paid,
+        final_balance
+    );
+
+    // Verify balance decreased appropriately
+    assert!(
+        final_balance < initial_balance,
+        "Balance should decrease after melt"
+    );
+}
+
+/// Tests melt where swap proofs barely cover swap_amount + input_fee.
+///
+/// This is a regression test for a bug where the swap-before-melt was called
+/// with include_fees=true, causing it to try to add output fees on top of
+/// swap_amount + input_fee. When proofs_to_swap had just barely enough value,
+/// this caused InsufficientFunds error.
+///
+/// Scenario (from the bug):
+/// - Balance: proofs like [4, 2, 1, 1] = 8 sats
+/// - Melt: 5 sats + 1 fee_reserve = 6 inputs_needed
+/// - target_fee = 1 (for optimal output split)
+/// - inputs_total_needed = 7
+/// - proofs_to_send = [4, 2] = 6, proofs_to_swap = [1, 1] = 2
+/// - swap_amount = 1 sat (7 - 6)
+/// - swap input_fee = 1 sat (2 proofs)
+/// - Before fix: include_fees=true tried to add output fee, causing failure
+/// - After fix: include_fees=false, swap succeeds
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_melt_swap_tight_margin_regression() {
+    setup_tracing();
+    let mint = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+
+    let wallet = create_test_wallet_for_mint(mint.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Rotate to keyset with 250 ppk = 0.25 sat per proof fee (same as original bug scenario)
+    // This means 4 proofs = 1 sat fee
+    mint.rotate_keyset(
+        CurrencyUnit::Sat,
+        cdk_integration_tests::standard_keyset_amounts(32),
+        250,
+    )
+    .await
+    .expect("Failed to rotate keyset");
+
+    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+
+    // Fund with 100 sats using default split to get optimal denominations
+    // This should give us proofs like [64, 32, 4] or similar power-of-2 split
+    let initial_amount = 100;
+    fund_wallet(wallet.clone(), initial_amount, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let initial_balance: u64 = wallet.total_balance().await.unwrap().into();
+    assert_eq!(initial_balance, initial_amount);
+
+    let proofs = wallet.get_unspent_proofs().await.unwrap();
+    let proof_amounts: Vec<u64> = proofs.iter().map(|p| u64::from(p.amount)).collect();
+    tracing::info!("Proofs after funding: {:?}", proof_amounts);
+
+    // Create melt quote for 5 sats (5000 msats)
+    // fee_reserve = max(1, 5 * 2%) = 1 sat
+    // inputs_needed = 5 + 1 = 6 sats
+    // The optimal split for 6 sats is [4, 2] (2 proofs)
+    // target_fee = 1 sat (2 proofs * 0.25, rounded up)
+    // inputs_total_needed = 7 sats
+    //
+    // If we don't have exact [4, 2] proofs, we'll need to swap.
+    // The swap path is what triggered the original bug when proofs_to_swap
+    // had tight margins and include_fees=true was incorrectly used.
+    let invoice = create_fake_invoice(5_000, "".to_string());
+    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+
+    let quote_amount: u64 = melt_quote.amount.into();
+    let fee_reserve: u64 = melt_quote.fee_reserve.into();
+
+    tracing::info!(
+        "Melt quote: amount={}, fee_reserve={}, inputs_needed={}",
+        quote_amount,
+        fee_reserve,
+        quote_amount + fee_reserve
+    );
+
+    // This is the key test: melt should succeed even when swap is needed
+    // Before the fix, include_fees=true in swap caused InsufficientFunds
+    // After the fix, include_fees=false allows the swap to succeed
+    let melted = wallet
+        .melt(&melt_quote.id)
+        .await
+        .expect("Melt should succeed with swap-before-melt (regression test)");
+
+    let melt_amount: u64 = melted.amount.into();
+    assert_eq!(melt_amount, quote_amount, "Melt amount should match quote");
+
+    let final_balance: u64 = wallet.total_balance().await.unwrap().into();
+    tracing::info!(
+        "Melt completed: amount={}, fee_paid={}, final_balance={}",
+        melted.amount,
+        melted.fee_paid,
+        final_balance
+    );
+
+    // Should have change remaining
+    assert!(
+        final_balance < initial_balance,
+        "Balance should decrease after melt"
+    );
+    assert!(final_balance > 0, "Should have change remaining");
+}
+
 /// Tests that swap correctly handles amount overflow:
 /// Attempts to create outputs that would overflow u64 when summed.
 /// This should be rejected before any database operations occur.

+ 110 - 8
crates/cdk/src/wallet/melt/melt_bolt11.rs

@@ -1,6 +1,7 @@
 use std::collections::HashMap;
 use std::str::FromStr;
 
+use cdk_common::amount::SplitTarget;
 use cdk_common::wallet::{Transaction, TransactionDirection};
 use cdk_common::PaymentMethod;
 use lightning_invoice::Bolt11Invoice;
@@ -8,12 +9,14 @@ use tracing::instrument;
 
 use crate::amount::to_unit;
 use crate::dhke::construct_proofs;
+use crate::nuts::nut00::ProofsMethods;
 use crate::nuts::{
     CurrencyUnit, MeltOptions, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltRequest,
-    PreMintSecrets, Proofs, ProofsMethods, State,
+    PreMintSecrets, Proofs, State,
 };
 use crate::types::{Melted, ProofInfo};
 use crate::util::unix_time;
+use crate::wallet::send::split_proofs_for_send;
 use crate::wallet::MeltQuote;
 use crate::{ensure_cdk, Amount, Error, Wallet};
 
@@ -164,7 +167,10 @@ impl Wallet {
 
         let active_keyset_id = self.fetch_active_keyset().await?.id;
 
-        let change_amount = proofs_total - quote_info.amount;
+        // Calculate change accounting for input fees
+        // The mint deducts input fees from available funds before calculating change
+        let input_fee = self.get_proofs_fee(&proofs).await?;
+        let change_amount = proofs_total - quote_info.amount - input_fee;
 
         let premint_secrets = if change_amount <= Amount::ZERO {
             PreMintSecrets::new(active_keyset_id)
@@ -387,25 +393,121 @@ impl Wallet {
 
         let inputs_needed_amount = quote_info.amount + quote_info.fee_reserve;
 
-        let available_proofs = self.get_unspent_proofs().await?;
-
         let active_keyset_ids = self
             .get_mint_keysets()
             .await?
             .into_iter()
             .map(|k| k.id)
             .collect();
-        let keyset_fees = self.get_keyset_fees_and_amounts().await?;
+        let keyset_fees_and_amounts = self.get_keyset_fees_and_amounts().await?;
+
+        let available_proofs = self.get_unspent_proofs().await?;
+
+        // Two-step proof selection for melt:
+        // Step 1: Try to select proofs that exactly match inputs_needed_amount.
+        //         If successful, no swap is required and we avoid paying swap fees.
+        // Step 2: If exact match not possible, we need to swap to get optimal denominations.
+        //         In this case, we must select more proofs to cover the additional swap fees.
+        {
+            let input_proofs = Wallet::select_proofs(
+                inputs_needed_amount,
+                available_proofs.clone(),
+                &active_keyset_ids,
+                &keyset_fees_and_amounts,
+                true,
+            )?;
+            let proofs_total = input_proofs.total_amount()?;
+
+            // If exact match, use proofs directly without swap
+            if proofs_total == inputs_needed_amount {
+                return self
+                    .melt_proofs_with_metadata(quote_id, input_proofs, metadata)
+                    .await;
+            }
+        }
+
+        let active_keyset_id = self.get_active_keyset().await?.id;
+        let fee_and_amounts = self
+            .get_keyset_fees_and_amounts_by_id(active_keyset_id)
+            .await?;
+
+        // Calculate optimal denomination split and the fee for those proofs
+        // First estimate based on inputs_needed_amount to get target_fee
+        let initial_split = inputs_needed_amount.split(&fee_and_amounts);
+        let target_fee = self
+            .get_proofs_fee_by_count(
+                vec![(active_keyset_id, initial_split.len() as u64)]
+                    .into_iter()
+                    .collect(),
+            )
+            .await?;
 
+        // Since we could not select the correct inputs amount needed for melting,
+        // we select again this time including the amount we will now have to pay as a fee for the swap.
+        let inputs_total_needed = inputs_needed_amount + target_fee;
+
+        // Recalculate target amounts based on the actual total we need (including fee)
+        let target_amounts = inputs_total_needed.split(&fee_and_amounts);
         let input_proofs = Wallet::select_proofs(
-            inputs_needed_amount,
+            inputs_total_needed,
             available_proofs,
             &active_keyset_ids,
-            &keyset_fees,
+            &keyset_fees_and_amounts,
             true,
         )?;
+        let proofs_total = input_proofs.total_amount()?;
+
+        // Need to swap to get exact denominations
+        tracing::debug!(
+            "Proofs total {} != inputs needed {}, swapping to get exact amount",
+            proofs_total,
+            inputs_total_needed
+        );
+
+        let keyset_fees: HashMap<cdk_common::Id, u64> = keyset_fees_and_amounts
+            .iter()
+            .map(|(key, values)| (*key, values.fee()))
+            .collect();
+
+        let split_result = split_proofs_for_send(
+            input_proofs,
+            &target_amounts,
+            inputs_total_needed,
+            target_fee,
+            &keyset_fees,
+            false,
+            false,
+        )?;
+
+        let mut final_proofs = split_result.proofs_to_send;
+
+        if !split_result.proofs_to_swap.is_empty() {
+            let swap_amount = inputs_total_needed
+                .checked_sub(final_proofs.total_amount()?)
+                .ok_or(Error::AmountOverflow)?;
+
+            tracing::debug!(
+                "Swapping {} proofs to get {} sats (swap fee: {} sats)",
+                split_result.proofs_to_swap.len(),
+                swap_amount,
+                split_result.swap_fee
+            );
+
+            if let Some(swapped) = self
+                .swap(
+                    Some(swap_amount),
+                    SplitTarget::None,
+                    split_result.proofs_to_swap,
+                    None,
+                    false, // fees already accounted for in inputs_total_needed
+                )
+                .await?
+            {
+                final_proofs.extend(swapped);
+            }
+        }
 
-        self.melt_proofs_with_metadata(quote_id, input_proofs, metadata)
+        self.melt_proofs_with_metadata(quote_id, final_proofs, metadata)
             .await
     }
 }

+ 186 - 0
crates/cdk/src/wallet/send.rs

@@ -511,6 +511,7 @@ pub struct ProofSplitResult {
 /// * `keyset_fees` - Map of keyset ID to fee_ppk
 /// * `force_swap` - If true, all proofs go to swap
 /// * `is_exact_or_offline` - If true (exact match or offline mode), all proofs go to send
+// TODO: Consider making this pub(crate) - this function is also used by melt operations
 pub fn split_proofs_for_send(
     proofs: Proofs,
     send_amounts: &[Amount],
@@ -1691,4 +1692,189 @@ mod tests {
         // Should handle this gracefully
         assert!(result.proofs_to_send.len() + result.proofs_to_swap.len() == 22);
     }
+
+    // ========================================================================
+    // Melt Use Case Tests
+    // For melt: amount = inputs_needed (quote + fee_reserve),
+    //           send_fee = target_fee (input fee for target proofs)
+    // ========================================================================
+
+    #[test]
+    fn test_melt_exact_proofs_no_swap() {
+        // Melt scenario: have exact proofs matching target denominations
+        // quote_amount + fee_reserve = 100, target_fee = 2
+        // Need proofs totaling 102
+        let input_proofs = proofs(&[64, 32, 4, 2]);
+        let target_amounts = amounts(&[64, 32, 4, 2]); // split of 102
+        let keyset_fees = keyset_fees_with_ppk(500); // 0.5 sat per proof
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &target_amounts,
+            Amount::from(100), // inputs_needed_amount
+            Amount::from(2),   // target_fee (4 proofs * 0.5)
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // All proofs match, no swap needed
+        assert_eq!(result.proofs_to_send.len(), 4);
+        assert!(result.proofs_to_swap.is_empty());
+        assert_eq!(result.swap_fee, Amount::ZERO);
+    }
+
+    #[test]
+    fn test_melt_excess_proofs_needs_swap() {
+        // Melt scenario: have proofs totaling more than needed
+        // Need 102 (100 + 2 fee), but have 128
+        let input_proofs = proofs(&[128]);
+        let target_amounts = amounts(&[64, 32, 4, 2]); // optimal split of 102
+        let keyset_fees = keyset_fees_with_ppk(500);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &target_amounts,
+            Amount::from(100),
+            Amount::from(2),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // 128 doesn't match any target, needs swap
+        assert!(result.proofs_to_send.is_empty());
+        assert_eq!(result.proofs_to_swap.len(), 1);
+        assert_eq!(result.proofs_to_swap[0].amount, Amount::from(128));
+    }
+
+    #[test]
+    fn test_melt_partial_match_with_swap() {
+        // Melt scenario: some proofs match, others need swap
+        // Need 100 + 2 fee = 102, have [64, 32, 16, 8] = 120
+        let input_proofs = proofs(&[64, 32, 16, 8]);
+        let target_amounts = amounts(&[64, 32, 4, 2]); // optimal split of 102
+        let keyset_fees = keyset_fees_with_ppk(500);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &target_amounts,
+            Amount::from(100),
+            Amount::from(2),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // 64 and 32 match, 16 and 8 go to swap
+        let send_amounts: Vec<u64> = result
+            .proofs_to_send
+            .iter()
+            .map(|p| p.amount.into())
+            .collect();
+        assert!(send_amounts.contains(&64));
+        assert!(send_amounts.contains(&32));
+
+        // 16 and 8 should be in swap to produce the remaining 6 (4+2)
+        assert!(!result.proofs_to_swap.is_empty());
+    }
+
+    #[test]
+    fn test_melt_with_exact_target_match() {
+        // Melt scenario: all target amounts match input proofs exactly
+        // When all targets are matched, unneeded proofs are dropped (not swapped)
+        let input_proofs = proofs(&[64, 32, 8, 4, 2]);
+        let target_amounts = amounts(&[64, 32, 8, 4, 2]); // exact match
+        let keyset_fees = keyset_fees_with_ppk(1000);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &target_amounts,
+            Amount::from(105), // amount
+            Amount::from(5),   // target fee
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // All proofs match target amounts
+        assert_eq!(result.proofs_to_send.len(), 5);
+        // No swap needed when all targets matched
+        assert!(result.proofs_to_swap.is_empty());
+    }
+
+    #[test]
+    fn test_melt_swap_fee_calculated() {
+        // Verify swap_fee is calculated correctly for melt
+        let input_proofs = proofs(&[64, 32, 8, 4]); // 108 total
+        let target_amounts = amounts(&[64, 32, 4]); // 100 split
+        let keyset_fees = keyset_fees_with_ppk(1000); // 1 sat per proof
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &target_amounts,
+            Amount::from(98),
+            Amount::from(2), // target fee for 3 proofs
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // 8 doesn't match, goes to swap
+        // swap_fee should be 1 sat (1 proof * 1000 ppk / 1000)
+        if !result.proofs_to_swap.is_empty() {
+            assert_eq!(
+                result.swap_fee,
+                Amount::from(result.proofs_to_swap.len() as u64)
+            );
+        }
+    }
+
+    #[test]
+    fn test_melt_large_quote_partial_match() {
+        // Realistic melt: input proofs don't contain all target denominations
+        // Input: [512, 256, 128, 64, 32, 16] = 1008
+        // Target: [512, 256, 128, 64, 32, 8, 4, 2, 1] = 1007 (need 8, 4, 2, 1 from swap)
+        let input_proofs = proofs(&[512, 256, 128, 64, 32, 16]);
+        let target_amounts = amounts(&[512, 256, 128, 64, 32, 8, 4, 2, 1]);
+        let keyset_fees = keyset_fees_with_ppk(375);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &target_amounts,
+            Amount::from(1004),
+            Amount::from(3),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // Check that matched proofs are in proofs_to_send
+        let send_amounts: Vec<u64> = result
+            .proofs_to_send
+            .iter()
+            .map(|p| p.amount.into())
+            .collect();
+
+        // These should match
+        assert!(send_amounts.contains(&512));
+        assert!(send_amounts.contains(&256));
+        assert!(send_amounts.contains(&128));
+        assert!(send_amounts.contains(&64));
+        assert!(send_amounts.contains(&32));
+
+        // 16 doesn't match any target, should be in swap to produce 8+4+2+1=15
+        let swap_amounts: Vec<u64> = result
+            .proofs_to_swap
+            .iter()
+            .map(|p| p.amount.into())
+            .collect();
+        assert!(swap_amounts.contains(&16));
+    }
 }