Parcourir la source

fix: proof selection with fees (#1345)

When selecting proofs with fee inclusion, adding extra proofs to cover
the fee shortfall also adds new fees. The previous single-pass approach
could leave the net amount below the requested amount. This changes to
an iterative loop that continues selecting additional proofs until the
net amount (total - fees) meets or exceeds the target.

refactor: remove swap before melt
tsk il y a 3 mois
Parent
commit
bcc68a790d

+ 14 - 20
crates/cdk-integration-tests/tests/fake_wallet.rs

@@ -15,6 +15,7 @@
 //! - Duplicate proof detection
 
 use std::sync::Arc;
+use std::time::Duration;
 
 use bip39::Mnemonic;
 use cashu::Amount;
@@ -882,7 +883,7 @@ async fn test_fake_mint_multiple_unit_melt() {
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
-    let proofs = proof_streams
+    let mut proofs = proof_streams
         .next()
         .await
         .expect("payment")
@@ -905,12 +906,15 @@ async fn test_fake_mint_multiple_unit_melt() {
     let mut proof_streams =
         wallet_usd.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
-    let usd_proofs = proof_streams
+    let mut usd_proofs = proof_streams
         .next()
         .await
         .expect("payment")
         .expect("no error");
 
+    usd_proofs.reverse();
+    proofs.reverse();
+
     {
         let inputs: Proofs = vec![
             proofs.first().expect("There is a proof").clone(),
@@ -1411,14 +1415,14 @@ async fn test_wallet_proof_recovery_after_failed_melt() {
 
     // Mint 100 sats
     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 initial_proofs = proof_streams
-        .next()
-        .await
-        .expect("payment")
-        .expect("no error");
-
-    let initial_ys: Vec<_> = initial_proofs.iter().map(|p| p.y().unwrap()).collect();
+    let _roof_streams = wallet
+        .wait_and_mint_quote(
+            mint_quote.clone(),
+            SplitTarget::default(),
+            None,
+            Duration::from_secs(1000),
+        )
+        .await;
 
     assert_eq!(wallet.total_balance().await.unwrap(), Amount::from(100));
 
@@ -1444,16 +1448,6 @@ async fn test_wallet_proof_recovery_after_failed_melt() {
         "Balance should be recovered"
     );
 
-    // Verify the proofs were swapped (different Ys)
-    let recovered_proofs = wallet.get_unspent_proofs().await.unwrap();
-    let recovered_ys: Vec<_> = recovered_proofs.iter().map(|p| p.y().unwrap()).collect();
-
-    // The Ys should be different (swapped to new proofs)
-    assert!(
-        initial_ys.iter().any(|y| !recovered_ys.contains(y)),
-        "Proofs should have been swapped to new ones"
-    );
-
     // Verify we can still spend the recovered proofs
     let valid_invoice = create_fake_invoice(7000, "".to_string());
     let valid_melt_quote = wallet

+ 8 - 0
crates/cdk-integration-tests/tests/test_fees.rs

@@ -9,9 +9,15 @@ use cdk::wallet::{ReceiveOptions, SendKind, SendOptions, Wallet};
 use cdk_integration_tests::init_regtest::get_temp_dir;
 use cdk_integration_tests::{create_invoice_for_env, get_mint_url_from_env, pay_if_regtest};
 use cdk_sqlite::wallet::memory;
+use tracing_subscriber::EnvFilter;
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_swap() {
+    // Set up logging
+    let default_filter = "debug";
+    let sqlx_filter = "sqlx=warn,hyper_util=warn,reqwest=warn,rustls=warn";
+    let env_filter = EnvFilter::new(format!("{},{}", default_filter, sqlx_filter));
+    tracing_subscriber::fmt().with_env_filter(env_filter).init();
     let seed = Mnemonic::generate(12).unwrap().to_seed_normalized("");
     let wallet = Wallet::new(
         &get_mint_url_from_env(),
@@ -39,6 +45,8 @@ async fn test_swap() {
 
     println!("{:?}", proofs);
 
+    println!("{:?}", wallet.get_mint_keysets().await.unwrap());
+
     let send = wallet
         .prepare_send(
             4.into(),

+ 12 - 3
crates/cdk/examples/p2pk.rs

@@ -27,7 +27,8 @@ async fn main() -> Result<(), Error> {
     let seed = random::<[u8; 64]>();
 
     // Define the mint URL and currency unit
-    let mint_url = "https://fake.thesimplekid.dev";
+    // let mint_url = "https://fake.thesimplekid.dev";
+    let mint_url = "https://testnut.cashu.space";
     let unit = CurrencyUnit::Sat;
     let amount = Amount::from(100);
 
@@ -60,10 +61,12 @@ async fn main() -> Result<(), Error> {
     let bal = wallet.total_balance().await?;
     println!("Total balance: {}", bal);
 
+    let token_amount_to_send = Amount::from(10);
+
     // Send a token with the specified amount and spending conditions
     let prepared_send = wallet
         .prepare_send(
-            10.into(),
+            token_amount_to_send,
             SendOptions {
                 conditions: Some(spending_conditions),
                 include_fee: true,
@@ -71,7 +74,11 @@ async fn main() -> Result<(), Error> {
             },
         )
         .await?;
-    println!("Fee: {}", prepared_send.fee());
+
+    let swap_fee = prepared_send.swap_fee();
+
+    println!("Fee: {}", swap_fee);
+
     let token = prepared_send.confirm(None).await?;
 
     println!("Created token locked to pubkey: {}", secret.public_key());
@@ -88,6 +95,8 @@ async fn main() -> Result<(), Error> {
         )
         .await?;
 
+    assert!(amount == token_amount_to_send);
+
     println!("Redeemed locked token worth: {}", u64::from(amount));
 
     Ok(())

+ 132 - 0
crates/cdk/src/fees.rs

@@ -82,4 +82,136 @@ mod tests {
         let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
         assert_eq!(sum_fee, 8.into());
     }
+
+    #[test]
+    fn test_fee_calculation_with_ppk_200() {
+        let keyset_id = Id::from_str("001711afb1de20cb").unwrap();
+
+        let fee_ppk = 200;
+
+        let mut keyset_fees = HashMap::new();
+        keyset_fees.insert(keyset_id, fee_ppk);
+
+        let mut proofs_count = HashMap::new();
+
+        proofs_count.insert(keyset_id, 1);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 1.into(), "1 proof: ceil(200/1000) = 1 sat");
+
+        proofs_count.insert(keyset_id, 3);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 1.into(), "3 proofs: ceil(600/1000) = 1 sat");
+
+        proofs_count.insert(keyset_id, 5);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 1.into(), "5 proofs: ceil(1000/1000) = 1 sat");
+
+        proofs_count.insert(keyset_id, 6);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 2.into(), "6 proofs: ceil(1200/1000) = 2 sats");
+    }
+
+    #[test]
+    fn test_fee_calculation_with_ppk_1000() {
+        let keyset_id = Id::from_str("001711afb1de20cb").unwrap();
+
+        let fee_ppk = 1000;
+
+        let mut keyset_fees = HashMap::new();
+        keyset_fees.insert(keyset_id, fee_ppk);
+
+        let mut proofs_count = HashMap::new();
+
+        proofs_count.insert(keyset_id, 1);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 1.into(), "1 proof at 1000 ppk = 1 sat");
+
+        proofs_count.insert(keyset_id, 2);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 2.into(), "2 proofs at 1000 ppk = 2 sats");
+
+        proofs_count.insert(keyset_id, 10);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 10.into(), "10 proofs at 1000 ppk = 10 sats");
+    }
+
+    #[test]
+    fn test_fee_calculation_zero_fee() {
+        let keyset_id = Id::from_str("001711afb1de20cb").unwrap();
+
+        let fee_ppk = 0;
+
+        let mut keyset_fees = HashMap::new();
+        keyset_fees.insert(keyset_id, fee_ppk);
+
+        let mut proofs_count = HashMap::new();
+
+        proofs_count.insert(keyset_id, 100);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 0.into(), "0 ppk means no fee: ceil(0/1000) = 0");
+    }
+
+    #[test]
+    fn test_fee_calculation_with_ppk_100() {
+        let keyset_id = Id::from_str("001711afb1de20cb").unwrap();
+
+        let fee_ppk = 100;
+
+        let mut keyset_fees = HashMap::new();
+        keyset_fees.insert(keyset_id, fee_ppk);
+
+        let mut proofs_count = HashMap::new();
+
+        proofs_count.insert(keyset_id, 1);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 1.into(), "1 proof: ceil(100/1000) = 1 sat");
+
+        proofs_count.insert(keyset_id, 10);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 1.into(), "10 proofs: ceil(1000/1000) = 1 sat");
+
+        proofs_count.insert(keyset_id, 11);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 2.into(), "11 proofs: ceil(1100/1000) = 2 sats");
+
+        proofs_count.insert(keyset_id, 91);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 10.into(), "91 proofs: ceil(9100/1000) = 10 sats");
+    }
+
+    #[test]
+    fn test_fee_calculation_unknown_keyset() {
+        let keyset_id = Id::from_str("001711afb1de20cb").unwrap();
+        let unknown_keyset_id = Id::from_str("001711afb1de20cc").unwrap();
+
+        let mut keyset_fees = HashMap::new();
+        keyset_fees.insert(keyset_id, 100);
+
+        let mut proofs_count = HashMap::new();
+        proofs_count.insert(unknown_keyset_id, 1);
+
+        let result = calculate_fee(&proofs_count, &keyset_fees);
+        assert!(result.is_err(), "Unknown keyset should return error");
+    }
+
+    #[test]
+    fn test_fee_calculation_multiple_keysets() {
+        let keyset_id_1 = Id::from_str("001711afb1de20cb").unwrap();
+        let keyset_id_2 = Id::from_str("001711afb1de20cc").unwrap();
+
+        let mut keyset_fees = HashMap::new();
+        keyset_fees.insert(keyset_id_1, 200);
+        keyset_fees.insert(keyset_id_2, 500);
+
+        let mut proofs_count = HashMap::new();
+        proofs_count.insert(keyset_id_1, 3);
+        proofs_count.insert(keyset_id_2, 2);
+
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(
+            sum_fee,
+            2.into(),
+            "3*200 + 2*500 = 1600, ceil(1600/1000) = 2"
+        );
+    }
 }

+ 4 - 2
crates/cdk/src/mint/melt/melt_saga/mod.rs

@@ -290,10 +290,12 @@ impl MeltSaga<Initial> {
 
         if input_amount < required_total {
             tracing::info!(
-                "Melt request unbalanced: inputs {}, amount {}, fee {}",
+                "Melt request unbalanced: inputs {}, amount {}, fee_reserve {}, input_fee {}, required {}",
                 input_amount,
                 quote.amount,
-                fee
+                quote.fee_reserve,
+                fee,
+                required_total
             );
             tx.rollback().await?;
             return Err(Error::TransactionUnbalanced(

+ 11 - 4
crates/cdk/src/wallet/issue/issue_bolt11.rs

@@ -220,18 +220,25 @@ impl Wallet {
             .get_keyset_fees_and_amounts_by_id(active_keyset_id)
             .await?;
 
+        let split_target = match amount_split_target {
+            SplitTarget::None => {
+                self.determine_split_target_values(amount_mintable, &fee_and_amounts)
+                    .await?
+            }
+            s => s,
+        };
+
         let premint_secrets = match &spending_conditions {
             Some(spending_conditions) => PreMintSecrets::with_conditions(
                 active_keyset_id,
                 amount_mintable,
-                &amount_split_target,
+                &split_target,
                 spending_conditions,
                 &fee_and_amounts,
             )?,
             None => {
-                // Calculate how many secrets we'll need
                 let amount_split =
-                    amount_mintable.split_targeted(&amount_split_target, &fee_and_amounts)?;
+                    amount_mintable.split_targeted(&split_target, &fee_and_amounts)?;
                 let num_secrets = amount_split.len() as u32;
 
                 tracing::debug!(
@@ -253,7 +260,7 @@ impl Wallet {
                     count,
                     &self.seed,
                     amount_mintable,
-                    &amount_split_target,
+                    &split_target,
                     &fee_and_amounts,
                 )?
             }

+ 11 - 4
crates/cdk/src/wallet/issue/issue_bolt12.rs

@@ -114,17 +114,24 @@ impl Wallet {
             return Err(Error::UnpaidQuote);
         }
 
+        let split_target = match amount_split_target {
+            SplitTarget::None => {
+                self.determine_split_target_values(amount, &fee_and_amounts)
+                    .await?
+            }
+            s => s,
+        };
+
         let premint_secrets = match &spending_conditions {
             Some(spending_conditions) => PreMintSecrets::with_conditions(
                 active_keyset_id,
                 amount,
-                &amount_split_target,
+                &split_target,
                 spending_conditions,
                 &fee_and_amounts,
             )?,
             None => {
-                // Calculate how many secrets we'll need without generating them
-                let amount_split = amount.split_targeted(&amount_split_target, &fee_and_amounts)?;
+                let amount_split = amount.split_targeted(&split_target, &fee_and_amounts)?;
                 let num_secrets = amount_split.len() as u32;
 
                 tracing::debug!(
@@ -146,7 +153,7 @@ impl Wallet {
                     count,
                     &self.seed,
                     amount,
-                    &amount_split_target,
+                    &split_target,
                     &fee_and_amounts,
                 )?
             }

+ 2 - 20
crates/cdk/src/wallet/melt/melt_bolt11.rs

@@ -1,7 +1,6 @@
 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;
@@ -390,7 +389,8 @@ impl Wallet {
             .map(|k| k.id)
             .collect();
         let keyset_fees = self.get_keyset_fees_and_amounts().await?;
-        let (mut input_proofs, mut exchange) = Wallet::select_exact_proofs(
+
+        let input_proofs = Wallet::select_proofs(
             inputs_needed_amount,
             available_proofs,
             &active_keyset_ids,
@@ -398,24 +398,6 @@ impl Wallet {
             true,
         )?;
 
-        if let Some((proof, exact_amount)) = exchange.take() {
-            let new_proofs = self
-                .swap(
-                    Some(exact_amount),
-                    SplitTarget::None,
-                    vec![proof],
-                    None,
-                    false,
-                )
-                .await?
-                .ok_or_else(|| {
-                    tracing::error!("Received empty proofs");
-                    Error::Internal
-                })?;
-
-            input_proofs.extend_from_slice(&new_proofs);
-        }
-
         self.melt_proofs_with_metadata(quote_id, input_proofs, metadata)
             .await
     }

+ 1482 - 57
crates/cdk/src/wallet/proofs.rs

@@ -248,11 +248,6 @@ impl Wallet {
         fees_and_keyset_amounts: &KeysetFeeAndAmounts,
         include_fees: bool,
     ) -> Result<Proofs, Error> {
-        tracing::debug!(
-            "amount={}, proofs={:?}",
-            amount,
-            proofs.iter().map(|p| p.amount.into()).collect::<Vec<u64>>()
-        );
         if amount == Amount::ZERO {
             return Ok(vec![]);
         }
@@ -263,14 +258,18 @@ impl Wallet {
         proofs.sort_by(|a, b| a.cmp(b).reverse());
 
         // Track selected proofs and remaining amounts (include all inactive proofs first)
-        let mut selected_proofs: HashSet<Proof> = proofs
+        let inactive_proofs: Proofs = proofs
             .iter()
             .filter(|p| !p.is_active(active_keyset_ids))
             .cloned()
             .collect();
+        let mut selected_proofs: HashSet<Proof> = inactive_proofs.iter().cloned().collect();
         if selected_proofs.total_amount()? >= amount {
             tracing::debug!("All inactive proofs are sufficient");
-            return Ok(selected_proofs.into_iter().collect());
+            // Still need to filter to minimum set, not return all of them
+            let mut inactive_selected = selected_proofs.into_iter().collect::<Vec<_>>();
+            inactive_selected.sort_by(|a, b| a.cmp(b).reverse());
+            return Self::select_least_amount_over(inactive_selected, amount);
         }
         let mut remaining_amounts: Vec<Amount> = Vec::new();
 
@@ -297,9 +296,15 @@ impl Wallet {
             }
         };
 
-        // Select proofs with the optimal amounts
-        for (_, fee_and_amounts) in fees_and_keyset_amounts.iter() {
-            // Split the amount into optimal amounts
+        // Get fee_and_amounts for the first active keyset (use for optimal amount splitting)
+        // We only need to split once - iterating over all keysets would cause duplicate selections
+        let fee_and_amounts = active_keyset_ids
+            .iter()
+            .find_map(|id| fees_and_keyset_amounts.get(id))
+            .or_else(|| fees_and_keyset_amounts.values().next());
+
+        // Select proofs with the optimal amounts (only split once, not per keyset)
+        if let Some(fee_and_amounts) = fee_and_amounts {
             for optimal_amount in amount.split(fee_and_amounts) {
                 if !select_proof(&proofs, optimal_amount, true) {
                     // Add the remaining amount to the remaining amounts because proof with the optimal amount was not found
@@ -310,17 +315,22 @@ impl Wallet {
 
         // If all the optimal amounts are selected, return the selected proofs
         if remaining_amounts.is_empty() {
-            tracing::debug!("All optimal amounts are selected");
+            let result: Proofs = selected_proofs.into_iter().collect();
+            tracing::debug!(
+                "All optimal amounts are selected, returning {} proofs with total {}",
+                result.len(),
+                result.total_amount().unwrap_or_default()
+            );
             if include_fees {
                 return Self::include_fees(
                     amount,
                     proofs,
-                    selected_proofs.into_iter().collect(),
+                    result,
                     active_keyset_ids,
                     fees_and_keyset_amounts,
                 );
             } else {
-                return Ok(selected_proofs.into_iter().collect());
+                return Ok(result);
             }
         }
 
@@ -438,53 +448,63 @@ impl Wallet {
         fees_and_keyset_amounts: &KeysetFeeAndAmounts,
     ) -> Result<Proofs, Error> {
         tracing::debug!("Including fees");
-        let fee = calculate_fee(
-            &selected_proofs.count_by_keyset(),
-            &fees_and_keyset_amounts
-                .iter()
-                .map(|(key, values)| (*key, values.fee()))
-                .collect(),
-        )
-        .unwrap_or_default();
-        let net_amount = selected_proofs.total_amount()? - fee;
-        tracing::debug!(
-            "Net amount={}, fee={}, total amount={}",
-            net_amount,
-            fee,
-            selected_proofs.total_amount()?
-        );
-        if net_amount >= amount {
-            tracing::debug!(
-                "Selected proofs: {:?}",
-                selected_proofs
-                    .iter()
-                    .map(|p| p.amount.into())
-                    .collect::<Vec<u64>>(),
-            );
-            return Ok(selected_proofs);
-        }
 
-        tracing::debug!("Net amount is less than the required amount");
-        let remaining_amount = amount - net_amount;
-        let remaining_proofs = proofs
+        let keyset_fees: HashMap<Id, u64> = fees_and_keyset_amounts
+            .iter()
+            .map(|(key, values)| (*key, values.fee()))
+            .collect();
+
+        let mut remaining_proofs: Proofs = proofs
             .into_iter()
             .filter(|p| !selected_proofs.contains(p))
-            .collect::<Proofs>();
-        selected_proofs.extend(Wallet::select_proofs(
-            remaining_amount,
-            remaining_proofs,
-            active_keyset_ids,
-            &HashMap::new(), // Fees are already calculated
-            false,
-        )?);
-        tracing::debug!(
-            "Selected proofs: {:?}",
-            selected_proofs
-                .iter()
-                .map(|p| p.amount.into())
-                .collect::<Vec<u64>>(),
-        );
-        Ok(selected_proofs)
+            .collect();
+
+        loop {
+            let fee =
+                calculate_fee(&selected_proofs.count_by_keyset(), &keyset_fees).unwrap_or_default();
+            let total = selected_proofs.total_amount()?;
+            let net_amount = total - fee;
+
+            tracing::debug!(
+                "Net amount={}, fee={}, total amount={}",
+                net_amount,
+                fee,
+                total
+            );
+
+            if net_amount >= amount {
+                tracing::debug!(
+                    "Selected proofs: {:?}",
+                    selected_proofs
+                        .iter()
+                        .map(|p| p.amount.into())
+                        .collect::<Vec<u64>>(),
+                );
+                return Ok(selected_proofs);
+            }
+
+            if remaining_proofs.is_empty() {
+                return Err(Error::InsufficientFunds);
+            }
+
+            let shortfall = amount - net_amount;
+            tracing::debug!("Net amount is less than required, shortfall={}", shortfall);
+
+            let additional = Wallet::select_proofs(
+                shortfall,
+                remaining_proofs.clone(),
+                active_keyset_ids,
+                fees_and_keyset_amounts,
+                false,
+            )?;
+
+            if additional.is_empty() {
+                return Err(Error::InsufficientFunds);
+            }
+
+            remaining_proofs.retain(|p| !additional.contains(p));
+            selected_proofs.extend(additional);
+        }
     }
 }
 
@@ -723,4 +743,1409 @@ mod tests {
         assert_eq!(selected_proofs.len(), 1);
         assert_eq!(selected_proofs[0].amount, 32.into());
     }
+
+    #[test]
+    fn test_select_proofs_include_fees_accounts_for_additional_proof_fees() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let mut keyset_fee_and_amounts = HashMap::new();
+        keyset_fee_and_amounts.insert(
+            active_id,
+            (100, (0..32).map(|x| 2u64.pow(x)).collect()).into(),
+        );
+
+        let proofs = vec![
+            proof(512),
+            proof(256),
+            proof(128),
+            proof(64),
+            proof(32),
+            proof(16),
+            proof(8),
+            proof(4),
+            proof(2),
+            proof(1),
+        ];
+
+        let amount: Amount = 1010.into();
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+        let net = total - fee;
+
+        assert!(
+            net >= amount,
+            "Net amount {} should be >= requested amount {} (total={}, fee={})",
+            net,
+            amount,
+            total,
+            fee
+        );
+    }
+
+    #[test]
+    fn test_select_proofs_include_fees_iterates_until_stable() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let mut keyset_fee_and_amounts = HashMap::new();
+        keyset_fee_and_amounts.insert(
+            active_id,
+            (100, (0..32).map(|x| 2u64.pow(x)).collect()).into(),
+        );
+
+        let mut proofs = Vec::new();
+        for i in 0..10 {
+            proofs.push(proof(1 << i));
+        }
+        proofs.push(proof(2));
+        proofs.push(proof(4));
+
+        let amount: Amount = 1010.into();
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+        let net = total - fee;
+
+        assert!(
+            net >= amount,
+            "Net amount {} should be >= requested amount {} (total={}, fee={}, num_proofs={})",
+            net,
+            amount,
+            total,
+            fee,
+            selected_proofs.len()
+        );
+    }
+
+    // ========================================================================
+    // Fee-Aware Proof Selection Tests (fee_ppk = 200)
+    // ========================================================================
+
+    fn keyset_fee_and_amounts_with_fee(
+        fee_ppk: u64,
+    ) -> HashMap<Id, cdk_common::amount::FeeAndAmounts> {
+        let mut keyset_fee_and_amounts = HashMap::new();
+        keyset_fee_and_amounts.insert(
+            id(),
+            (fee_ppk, (0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>()).into(),
+        );
+        keyset_fee_and_amounts
+    }
+
+    fn standard_proofs() -> Vec<Proof> {
+        vec![
+            proof(1),
+            proof(2),
+            proof(4),
+            proof(8),
+            proof(16),
+            proof(32),
+            proof(64),
+            proof(128),
+            proof(256),
+            proof(512),
+            proof(1024),
+            proof(2048),
+            proof(4096),
+        ]
+    }
+
+    fn fragmented_proofs() -> Vec<Proof> {
+        let mut proofs = Vec::new();
+        for _ in 0..10 {
+            proofs.push(proof(1));
+        }
+        for _ in 0..8 {
+            proofs.push(proof(2));
+        }
+        for _ in 0..6 {
+            proofs.push(proof(4));
+        }
+        for _ in 0..5 {
+            proofs.push(proof(8));
+        }
+        for _ in 0..4 {
+            proofs.push(proof(16));
+        }
+        for _ in 0..3 {
+            proofs.push(proof(32));
+        }
+        for _ in 0..2 {
+            proofs.push(proof(64));
+        }
+        for _ in 0..2 {
+            proofs.push(proof(128));
+        }
+        for _ in 0..2 {
+            proofs.push(proof(256));
+        }
+        for _ in 0..2 {
+            proofs.push(proof(512));
+        }
+        for _ in 0..2 {
+            proofs.push(proof(1024));
+        }
+        for _ in 0..2 {
+            proofs.push(proof(2048));
+        }
+        proofs
+    }
+
+    fn large_proofs() -> Vec<Proof> {
+        vec![
+            proof(4096),
+            proof(2048),
+            proof(1024),
+            proof(512),
+            proof(256),
+        ]
+    }
+
+    fn mixed_proofs() -> Vec<Proof> {
+        vec![
+            proof(4096),
+            proof(1024),
+            proof(256),
+            proof(256),
+            proof(128),
+            proof(64),
+            proof(32),
+            proof(16),
+            proof(8),
+            proof(4),
+            proof(2),
+            proof(1),
+            proof(1),
+        ]
+    }
+
+    #[test]
+    fn test_select_proofs_with_fees_single_proof_exact() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200);
+
+        let proofs = vec![proof(4096)];
+        let amount: Amount = 4095.into();
+
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+        let net = total - fee;
+
+        assert_eq!(selected_proofs.len(), 1);
+        assert_eq!(selected_proofs[0].amount, 4096.into());
+        assert!(net >= amount, "4096 - 1 (fee) = 4095 >= 4095");
+    }
+
+    #[test]
+    fn test_select_proofs_with_fees_single_proof_insufficient() {
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200);
+
+        let proofs = vec![proof(4096)];
+        let amount: Amount = 4096.into();
+
+        let result = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        );
+
+        assert!(result.is_err(), "4096 - 1 (fee) = 4095 < 4096, should fail");
+    }
+
+    #[test]
+    fn test_select_proofs_with_fees_two_proofs_fee_threshold() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200);
+
+        let proofs = vec![proof(4096), proof(1024)];
+        let amount: Amount = 5000.into();
+
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+        let net = total - fee;
+
+        assert!(net >= amount, "5120 - 1 = 5119 >= 5000");
+    }
+
+    #[test]
+    fn test_select_proofs_with_fees_iterative_fee_adjustment() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200);
+
+        let proofs = vec![
+            proof(4096),
+            proof(1024),
+            proof(512),
+            proof(256),
+            proof(128),
+            proof(8),
+        ];
+        let amount: Amount = 5000.into();
+
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+        let net = total - fee;
+
+        assert!(
+            net >= amount,
+            "Net amount {} should be >= requested amount {} (total={}, fee={})",
+            net,
+            amount,
+            total,
+            fee
+        );
+    }
+
+    #[test]
+    fn test_select_proofs_with_fees_fee_increases_with_proofs() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200);
+
+        let proofs = vec![
+            proof(1024),
+            proof(1024),
+            proof(1024),
+            proof(1024),
+            proof(1024),
+        ];
+        let amount: Amount = 5000.into();
+
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+        let net = total - fee;
+
+        assert!(
+            net >= amount,
+            "Net amount {} should be >= requested amount {}",
+            net,
+            amount
+        );
+    }
+
+    #[test]
+    fn test_select_proofs_with_fees_standard_proofs() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200);
+
+        let proofs = standard_proofs();
+        let amount: Amount = 5000.into();
+
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+        let net = total - fee;
+
+        assert!(
+            net >= amount,
+            "Standard proofs: net {} should be >= {} (total={}, fee={}, num_proofs={})",
+            net,
+            amount,
+            total,
+            fee,
+            selected_proofs.len()
+        );
+    }
+
+    #[test]
+    fn test_select_proofs_with_fees_mixed_proofs() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200);
+
+        let proofs = mixed_proofs();
+        let amount: Amount = 5000.into();
+
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+        let net = total - fee;
+
+        assert!(
+            net >= amount,
+            "Mixed proofs: net {} should be >= {} (total={}, fee={}, num_proofs={})",
+            net,
+            amount,
+            total,
+            fee,
+            selected_proofs.len()
+        );
+    }
+
+    // ========================================================================
+    // High Fee Tests (fee_ppk = 1000, i.e., 1 sat per proof)
+    // ========================================================================
+
+    #[test]
+    fn test_select_proofs_high_fees_one_sat_per_proof() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(1000);
+
+        let proofs = vec![
+            proof(4096),
+            proof(4096),
+            proof(4096),
+            proof(4096),
+            proof(4096),
+            proof(4096),
+            proof(4096),
+            proof(4096),
+            proof(4096),
+            proof(4096),
+            proof(4096),
+            proof(512),
+            proof(1024),
+            proof(1024),
+            proof(1024),
+            proof(1024),
+            proof(1024),
+            proof(1024),
+            proof(1024),
+            proof(1024),
+            proof(512),
+            proof(512),
+            proof(512),
+            proof(512),
+            proof(512),
+            proof(512),
+            proof(256),
+            proof(256),
+            proof(256),
+            proof(256),
+            proof(256),
+            proof(256),
+            proof(128),
+            proof(128),
+            proof(128),
+            proof(128),
+            proof(128),
+            proof(128),
+            proof(128),
+            proof(8),
+            proof(8),
+            proof(8),
+            proof(8),
+            proof(8),
+            proof(8),
+            proof(8),
+            proof(4),
+            proof(4),
+            proof(4),
+            proof(4),
+            proof(4),
+            proof(4),
+            proof(4),
+            proof(2),
+            proof(2),
+            proof(2),
+            proof(2),
+            proof(2),
+            proof(2),
+            proof(1),
+            proof(1),
+            proof(1),
+            proof(1),
+            proof(1),
+            proof(4096),
+        ];
+        let amount: Amount = 5000.into();
+
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+
+        let net = total - fee;
+
+        assert!(
+            net == amount,
+            "Selected proofs should cover amount after fees"
+        );
+        assert!(fee == Amount::from(selected_proofs.len() as u64));
+        assert!(fee > Amount::ZERO);
+    }
+
+    #[test]
+    fn test_select_proofs_high_fees_prefers_larger_proofs() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(1000);
+
+        let mut proofs = Vec::new();
+        for _ in 0..100 {
+            proofs.push(proof(64));
+        }
+        proofs.push(proof(4096));
+        proofs.push(proof(1024));
+
+        let amount: Amount = 5000.into();
+
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+        let net = total - fee;
+
+        assert!(net >= amount, "Net amount {} should be >= {}", net, amount);
+    }
+
+    #[test]
+    fn test_select_proofs_high_fees_exact_with_fee() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(1000);
+
+        let proofs = vec![proof(4096), proof(1024)];
+        let amount: Amount = 5118.into();
+
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+        let net = total - fee;
+
+        assert_eq!(selected_proofs.len(), 2);
+        assert_eq!(net, 5118.into(), "5120 - 2 = 5118");
+    }
+
+    #[test]
+    fn test_select_proofs_high_fees_large_proofs() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(1000);
+
+        let proofs = large_proofs();
+        let amount: Amount = 5000.into();
+
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+        let net = total - fee;
+
+        assert!(
+            net >= amount,
+            "Large proofs: net {} should be >= {} (total={}, fee={}, num_proofs={})",
+            net,
+            amount,
+            total,
+            fee,
+            selected_proofs.len()
+        );
+    }
+
+    // ========================================================================
+    // Edge Case Tests
+    // ========================================================================
+
+    #[test]
+    fn test_select_proofs_with_fees_zero_amount() {
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200);
+
+        let proofs = standard_proofs();
+        let amount: Amount = 0.into();
+
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        assert_eq!(
+            selected_proofs.len(),
+            0,
+            "Zero amount should return empty selection"
+        );
+    }
+
+    #[test]
+    fn test_select_proofs_with_fees_empty_proofs() {
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200);
+
+        let proofs: Vec<Proof> = vec![];
+        let amount: Amount = 5000.into();
+
+        let result = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        );
+
+        assert!(
+            result.is_err(),
+            "Empty proofs should return InsufficientFunds"
+        );
+    }
+
+    #[test]
+    fn test_select_proofs_with_fees_all_proofs_same_size() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200);
+
+        let proofs = vec![
+            proof(1024),
+            proof(1024),
+            proof(1024),
+            proof(1024),
+            proof(1024),
+            proof(1024),
+        ];
+        let amount: Amount = 5000.into();
+
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+        let net = total - fee;
+
+        assert!(net >= amount, "Net {} should be >= {}", net, amount);
+    }
+
+    #[test]
+    fn test_select_proofs_with_fees_fee_exceeds_small_proof() {
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(1000);
+
+        let proofs = vec![proof(1)];
+        let amount: Amount = 1.into();
+
+        let result = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        );
+
+        assert!(
+            result.is_err(),
+            "1 sat proof with 1 sat fee is uneconomical"
+        );
+    }
+
+    #[test]
+    fn test_select_proofs_with_fees_barely_sufficient() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200);
+
+        let proofs = vec![
+            proof(4096),
+            proof(1024),
+            proof(512),
+            proof(256),
+            proof(128),
+            proof(8),
+            proof(1),
+        ];
+        let amount: Amount = 5000.into();
+
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+        let net = total - fee;
+
+        assert!(
+            net >= amount,
+            "Barely sufficient: net {} should be >= {} (total={}, fee={})",
+            net,
+            amount,
+            total,
+            fee
+        );
+    }
+
+    // ========================================================================
+    // Stress Tests
+    // ========================================================================
+
+    #[test]
+    fn test_select_proofs_many_small_proofs_with_fees() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(100);
+
+        let mut proofs: Vec<Proof> = (0..500).map(|_| proof(16)).collect();
+        proofs.extend((0..200).map(|_| proof(8)));
+        proofs.extend((0..100).map(|_| proof(4)));
+
+        let amount: Amount = 5000.into();
+
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+        let net = total - fee;
+
+        assert!(
+            net >= amount,
+            "Net {} should be >= {} (total={}, fee={}, num_proofs={})",
+            net,
+            amount,
+            total,
+            fee,
+            selected_proofs.len()
+        );
+    }
+
+    #[test]
+    fn test_select_proofs_fee_convergence_with_many_proofs() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(100);
+
+        let proofs: Vec<Proof> = (0..600).map(|_| proof(16)).collect();
+        let amount: Amount = 5000.into();
+
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+        let net = total - fee;
+
+        assert!(
+            net >= amount,
+            "Fee convergence should work: net={}, amount={}, total={}, fee={}, proofs={}",
+            net,
+            amount,
+            total,
+            fee,
+            selected_proofs.len()
+        );
+    }
+
+    #[test]
+    fn test_select_proofs_fragmented_proofs_with_fees() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200);
+
+        let proofs = fragmented_proofs();
+        let amount: Amount = 5000.into();
+
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+        let net = total - fee;
+
+        assert!(
+            net >= amount,
+            "Fragmented proofs: net {} should be >= {} (total={}, fee={}, num_proofs={})",
+            net,
+            amount,
+            total,
+            fee,
+            selected_proofs.len()
+        );
+    }
+
+    // ========================================================================
+    // Regression Tests
+    // ========================================================================
+
+    #[test]
+    fn test_regression_swap_insufficient_small_proof() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200);
+
+        let proofs = vec![
+            proof(4096),
+            proof(1024),
+            proof(512),
+            proof(256),
+            proof(128),
+            proof(8),
+        ];
+        let amount: Amount = 5000.into();
+
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+        let net = total - fee;
+
+        assert!(
+            net >= amount,
+            "Regression: should handle small proofs correctly. Net={}, expected >= {}",
+            net,
+            amount
+        );
+    }
+
+    #[test]
+    fn test_regression_fragmented_proofs_with_fees() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200);
+
+        let mut proofs = Vec::new();
+        for _ in 0..20 {
+            proofs.push(proof(1));
+        }
+        for _ in 0..15 {
+            proofs.push(proof(2));
+        }
+        for _ in 0..12 {
+            proofs.push(proof(4));
+        }
+        for _ in 0..10 {
+            proofs.push(proof(8));
+        }
+        for _ in 0..8 {
+            proofs.push(proof(16));
+        }
+        for _ in 0..6 {
+            proofs.push(proof(32));
+        }
+        for _ in 0..5 {
+            proofs.push(proof(64));
+        }
+        for _ in 0..4 {
+            proofs.push(proof(128));
+        }
+        for _ in 0..3 {
+            proofs.push(proof(256));
+        }
+        for _ in 0..3 {
+            proofs.push(proof(512));
+        }
+        for _ in 0..2 {
+            proofs.push(proof(1024));
+        }
+        for _ in 0..2 {
+            proofs.push(proof(2048));
+        }
+
+        let amount: Amount = 5000.into();
+
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+        let net = total - fee;
+
+        assert!(
+            net >= amount,
+            "Fragmented proofs should work: net={}, amount={}",
+            net,
+            amount
+        );
+    }
+
+    #[test]
+    fn test_regression_exact_amount_with_multiple_denominations() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        use crate::fees::calculate_fee;
+
+        let active_id = id();
+        let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200);
+
+        let proofs = vec![
+            proof(4096),
+            proof(1024),
+            proof(512),
+            proof(256),
+            proof(128),
+            proof(8),
+            proof(4),
+            proof(2),
+            proof(1),
+        ];
+        let amount: Amount = 5007.into();
+
+        let selected_proofs = Wallet::select_proofs(
+            amount,
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            true,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        let fee = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &keyset_fee_and_amounts
+                .iter()
+                .map(|(k, v)| (*k, v.fee()))
+                .collect(),
+        )
+        .unwrap();
+        let net = total - fee;
+
+        assert!(
+            net >= amount,
+            "Exact amount with multiple denominations: net {} should be >= {} (total={}, fee={})",
+            net,
+            amount,
+            total,
+            fee
+        );
+    }
+
+    // ========================================================================
+    // Inactive Keyset Tests
+    // ========================================================================
+
+    fn inactive_id() -> Id {
+        Id::from_bytes(&[0x00, 1, 1, 1, 1, 1, 1, 1]).unwrap()
+    }
+
+    fn proof_with_keyset(amount: u64, keyset_id: Id) -> Proof {
+        Proof::new(
+            Amount::from(amount),
+            keyset_id,
+            Secret::generate(),
+            PublicKey::from_hex(
+                "03deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
+            )
+            .unwrap(),
+        )
+    }
+
+    #[test]
+    fn test_select_proofs_inactive_keyset_exact_amount() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        let inactive = inactive_id();
+        let active = id();
+
+        let mut keyset_fee_and_amounts = HashMap::new();
+        keyset_fee_and_amounts.insert(
+            active,
+            (0, (0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>()).into(),
+        );
+
+        let proofs = vec![
+            proof_with_keyset(1, inactive),
+            proof_with_keyset(1, inactive),
+            proof_with_keyset(2, inactive),
+            proof_with_keyset(4, inactive),
+            proof_with_keyset(8, inactive),
+            proof_with_keyset(16, inactive),
+        ];
+
+        let selected_proofs = Wallet::select_proofs(
+            4.into(),
+            proofs,
+            &vec![active],
+            &keyset_fee_and_amounts,
+            false,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        assert_eq!(
+            total,
+            4.into(),
+            "Should select exactly 4 sats worth of proofs from inactive keyset, got {}",
+            total
+        );
+    }
+
+    #[test]
+    fn test_select_proofs_inactive_keyset_minimum_over() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        let inactive = inactive_id();
+        let active = id();
+
+        let mut keyset_fee_and_amounts = HashMap::new();
+        keyset_fee_and_amounts.insert(
+            active,
+            (0, (0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>()).into(),
+        );
+
+        let proofs = vec![
+            proof_with_keyset(8, inactive),
+            proof_with_keyset(16, inactive),
+            proof_with_keyset(32, inactive),
+        ];
+
+        let selected_proofs = Wallet::select_proofs(
+            5.into(),
+            proofs,
+            &vec![active],
+            &keyset_fee_and_amounts,
+            false,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        assert_eq!(
+            total,
+            8.into(),
+            "Should select minimum amount (8) that covers 5 sats, got {}",
+            total
+        );
+        assert_eq!(selected_proofs.len(), 1, "Should select only 1 proof");
+    }
+
+    #[test]
+    fn test_select_proofs_active_keyset_exact_4_sats_with_fee() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        let active = id();
+
+        let mut keyset_fee_and_amounts = HashMap::new();
+        keyset_fee_and_amounts.insert(
+            active,
+            (100, (0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>()).into(),
+        );
+
+        let proofs = vec![
+            proof(1),
+            proof(1),
+            proof(1),
+            proof(1),
+            proof(2),
+            proof(2),
+            proof(2),
+            proof(2),
+            proof(4),
+            proof(4),
+            proof(4),
+            proof(4),
+            proof(8),
+            proof(8),
+            proof(8),
+            proof(16),
+            proof(16),
+            proof(16),
+        ];
+
+        let selected_proofs = Wallet::select_proofs(
+            4.into(),
+            proofs,
+            &vec![active],
+            &keyset_fee_and_amounts,
+            false,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        assert_eq!(
+            total,
+            4.into(),
+            "Should select exactly 4 sats worth of proofs, got {}",
+            total
+        );
+        assert_eq!(
+            selected_proofs.len(),
+            1,
+            "Should select only 1 proof (the 4-sat one)"
+        );
+    }
+
+    #[test]
+    fn test_select_proofs_multiple_keysets_does_not_double_select() {
+        use cdk_common::nuts::nut00::ProofsMethods;
+
+        let active = id();
+        let other_keyset = inactive_id();
+
+        let mut keyset_fee_and_amounts = HashMap::new();
+        keyset_fee_and_amounts.insert(
+            active,
+            (100, (0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>()).into(),
+        );
+        keyset_fee_and_amounts.insert(
+            other_keyset,
+            (100, (0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>()).into(),
+        );
+
+        let proofs = vec![
+            proof(1),
+            proof(1),
+            proof(1),
+            proof(1),
+            proof(2),
+            proof(2),
+            proof(2),
+            proof(2),
+            proof(4),
+            proof(4),
+            proof(4),
+            proof(4),
+            proof(8),
+            proof(8),
+            proof(8),
+            proof(16),
+            proof(16),
+            proof(16),
+        ];
+
+        let selected_proofs = Wallet::select_proofs(
+            4.into(),
+            proofs,
+            &vec![active],
+            &keyset_fee_and_amounts,
+            false,
+        )
+        .unwrap();
+
+        let total = selected_proofs.total_amount().unwrap();
+        assert_eq!(
+            total,
+            4.into(),
+            "Should select exactly 4 sats worth even with multiple keysets in fee map, got {}",
+            total
+        );
+        assert_eq!(selected_proofs.len(), 1, "Should select only 1 proof");
+    }
 }

+ 1264 - 28
crates/cdk/src/wallet/send.rs

@@ -4,10 +4,12 @@ use std::fmt::Debug;
 use cdk_common::nut02::KeySetInfosMethods;
 use cdk_common::util::unix_time;
 use cdk_common::wallet::{Transaction, TransactionDirection};
+use cdk_common::Id;
 use tracing::instrument;
 
 use super::SendKind;
 use crate::amount::SplitTarget;
+use crate::fees::calculate_fee;
 use crate::nuts::nut00::ProofsMethods;
 use crate::nuts::{Proofs, SpendingConditions, State, Token};
 use crate::{Amount, Error, Wallet};
@@ -82,8 +84,34 @@ impl Wallet {
             .map(|k| k.id)
             .collect();
 
+        // When including fees, we need to account for both:
+        // 1. Input fees (to spend the selected proofs)
+        // 2. Output fees (send_fee - fee to redeem the token we create)
+        //
+        // If proofs don't exactly match the desired denominations, a swap is needed.
+        // The swap consumes the input fee, and the outputs must cover amount + send_fee.
+        // So we select proofs for (amount + send_fee) to ensure the swap can succeed.
+        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?;
+
+        let selection_amount = if opts.include_fee {
+            let send_split = amount.split_with_fee(&fee_and_amounts)?;
+            let send_fee = self
+                .get_proofs_fee_by_count(
+                    vec![(active_keyset_id, send_split.len() as u64)]
+                        .into_iter()
+                        .collect(),
+                )
+                .await?;
+            amount + send_fee
+        } else {
+            amount
+        };
+
         let selected_proofs = Wallet::select_proofs(
-            amount,
+            selection_amount,
             available_proofs,
             &active_keyset_ids,
             &keyset_fees,
@@ -163,39 +191,36 @@ impl Wallet {
             exact_proofs &= proofs.len() <= max_proofs;
         }
 
-        // Split proofs to swap and send
-        let mut proofs_to_swap = Proofs::new();
-        let mut proofs_to_send = Proofs::new();
-        if force_swap {
-            proofs_to_swap = proofs;
-        } else if exact_proofs || opts.send_kind.is_offline() || opts.send_kind.has_tolerance() {
-            proofs_to_send = proofs;
-        } else {
-            let mut remaining_send_amounts = send_amounts.clone();
-            for proof in proofs {
-                if let Some(idx) = remaining_send_amounts
-                    .iter()
-                    .position(|a| a == &proof.amount)
-                {
-                    proofs_to_send.push(proof);
-                    remaining_send_amounts.remove(idx);
-                } else {
-                    proofs_to_swap.push(proof);
-                }
-            }
-        }
+        // Determine if we should send all proofs directly
+        let is_exact_or_offline =
+            exact_proofs || opts.send_kind.is_offline() || opts.send_kind.has_tolerance();
+
+        // Get keyset fees for the split function
+        let keyset_fees_and_amounts = self.get_keyset_fees_and_amounts().await?;
+        let keyset_fees: HashMap<Id, u64> = keyset_fees_and_amounts
+            .iter()
+            .map(|(key, values)| (*key, values.fee()))
+            .collect();
 
-        // Calculate swap fee
-        let swap_fee = self.get_proofs_fee(&proofs_to_swap).await?;
+        // Split proofs between send and swap
+        let split_result = split_proofs_for_send(
+            proofs,
+            &send_amounts,
+            amount,
+            send_fee,
+            &keyset_fees,
+            force_swap,
+            is_exact_or_offline,
+        )?;
 
         // Return prepared send
         Ok(PreparedSend {
             wallet: self.clone(),
             amount,
             options: opts,
-            proofs_to_swap,
-            swap_fee,
-            proofs_to_send,
+            proofs_to_swap: split_result.proofs_to_swap,
+            swap_fee: split_result.swap_fee,
+            proofs_to_send: split_result.proofs_to_send,
             send_fee,
         })
     }
@@ -279,8 +304,11 @@ impl PreparedSend {
 
         // Swap proofs if necessary
         if !self.proofs_to_swap.is_empty() {
-            let swap_amount = total_send_amount - proofs_to_send.total_amount()?;
+            let swap_amount = total_send_amount
+                .checked_sub(proofs_to_send.total_amount()?)
+                .unwrap_or(Amount::ZERO);
             tracing::debug!("Swapping proofs; swap_amount={:?}", swap_amount);
+
             if let Some(proofs) = self
                 .wallet
                 .swap(
@@ -456,3 +484,1211 @@ impl SendMemo {
         }
     }
 }
+
+/// Result of splitting proofs for a send operation
+#[derive(Debug, Clone)]
+pub struct ProofSplitResult {
+    /// Proofs that can be sent directly (matching desired denominations)
+    pub proofs_to_send: Proofs,
+    /// Proofs that need to be swapped first
+    pub proofs_to_swap: Proofs,
+    /// Fee required for the swap operation
+    pub swap_fee: Amount,
+}
+
+/// Split proofs between those to send directly and those requiring swap.
+///
+/// This is a pure function that implements the core logic of `internal_prepare_send`:
+/// 1. Match proofs to desired send amounts
+/// 2. Ensure proofs_to_swap can cover swap fees plus needed output
+/// 3. Move proofs from send to swap if needed to cover fees
+///
+/// # Arguments
+/// * `proofs` - All selected proofs to split
+/// * `send_amounts` - Desired output denominations
+/// * `amount` - Amount to send
+/// * `send_fee` - Fee the recipient will pay to redeem
+/// * `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
+pub fn split_proofs_for_send(
+    proofs: Proofs,
+    send_amounts: &[Amount],
+    amount: Amount,
+    send_fee: Amount,
+    keyset_fees: &HashMap<Id, u64>,
+    force_swap: bool,
+    is_exact_or_offline: bool,
+) -> Result<ProofSplitResult, Error> {
+    let mut proofs_to_swap = Proofs::new();
+    let mut proofs_to_send = Proofs::new();
+
+    if force_swap {
+        proofs_to_swap = proofs;
+    } else if is_exact_or_offline {
+        proofs_to_send = proofs;
+    } else {
+        let mut remaining_send_amounts: Vec<Amount> = send_amounts.to_vec();
+        for proof in proofs {
+            if let Some(idx) = remaining_send_amounts
+                .iter()
+                .position(|a| a == &proof.amount)
+            {
+                proofs_to_send.push(proof);
+                remaining_send_amounts.remove(idx);
+            } else {
+                proofs_to_swap.push(proof);
+            }
+        }
+
+        // Check if swap is actually needed
+        if !proofs_to_swap.is_empty() {
+            let swap_output_needed = (amount + send_fee)
+                .checked_sub(proofs_to_send.total_amount()?)
+                .unwrap_or(Amount::ZERO);
+
+            if swap_output_needed == Amount::ZERO {
+                // proofs_to_send already covers the full amount, no swap needed
+                // Clear proofs_to_swap - these are just leftover proofs that don't match
+                // any send denomination but aren't needed for the send
+                proofs_to_swap.clear();
+            } else {
+                // Ensure proofs_to_swap can cover the swap's input fee plus the needed output
+                loop {
+                    let swap_input_fee =
+                        calculate_fee(&proofs_to_swap.count_by_keyset(), keyset_fees)?;
+                    let swap_total = proofs_to_swap.total_amount()?;
+
+                    let swap_can_produce = swap_total.checked_sub(swap_input_fee);
+
+                    match swap_can_produce {
+                        Some(can_produce) if can_produce >= swap_output_needed => {
+                            break;
+                        }
+                        _ => {
+                            if proofs_to_send.is_empty() {
+                                return Err(Error::InsufficientFunds);
+                            }
+
+                            // Move the smallest proof from send to swap
+                            proofs_to_send.sort_by(|a, b| a.amount.cmp(&b.amount));
+                            let proof_to_move = proofs_to_send.remove(0);
+                            proofs_to_swap.push(proof_to_move);
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    let swap_fee = calculate_fee(&proofs_to_swap.count_by_keyset(), keyset_fees)?;
+
+    Ok(ProofSplitResult {
+        proofs_to_send,
+        proofs_to_swap,
+        swap_fee,
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use cdk_common::secret::Secret;
+    use cdk_common::{Amount, Id, Proof, PublicKey};
+
+    use super::*;
+
+    fn id() -> Id {
+        Id::from_bytes(&[0; 8]).unwrap()
+    }
+
+    fn proof(amount: u64) -> Proof {
+        Proof::new(
+            Amount::from(amount),
+            id(),
+            Secret::generate(),
+            PublicKey::from_hex(
+                "03deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
+            )
+            .unwrap(),
+        )
+    }
+
+    fn proofs(amounts: &[u64]) -> Proofs {
+        amounts.iter().map(|&a| proof(a)).collect()
+    }
+
+    fn keyset_fees_with_ppk(fee_ppk: u64) -> HashMap<Id, u64> {
+        let mut fees = HashMap::new();
+        fees.insert(id(), fee_ppk);
+        fees
+    }
+
+    fn amounts(values: &[u64]) -> Vec<Amount> {
+        values.iter().map(|&v| Amount::from(v)).collect()
+    }
+
+    // ========================================================================
+    // No Swap Needed (Exact Proofs) Tests
+    // ========================================================================
+
+    #[test]
+    fn test_split_exact_match_simple() {
+        let input_proofs = proofs(&[8, 2]);
+        let send_amounts = amounts(&[8, 2]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(10),
+            Amount::from(1),
+            &keyset_fees,
+            false,
+            true, // exact match
+        )
+        .unwrap();
+
+        assert_eq!(result.proofs_to_send.len(), 2);
+        assert!(result.proofs_to_swap.is_empty());
+        assert_eq!(result.swap_fee, Amount::ZERO);
+    }
+
+    #[test]
+    fn test_split_exact_match_six_proofs() {
+        let input_proofs = proofs(&[2048, 1024, 512, 256, 128, 32]);
+        let send_amounts = amounts(&[2048, 1024, 512, 256, 128, 32]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(4000),
+            Amount::from(2),
+            &keyset_fees,
+            false,
+            true,
+        )
+        .unwrap();
+
+        assert_eq!(result.proofs_to_send.len(), 6);
+        assert!(result.proofs_to_swap.is_empty());
+    }
+
+    #[test]
+    fn test_split_exact_match_ten_proofs() {
+        let input_proofs = proofs(&[4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8]);
+        let send_amounts = amounts(&[4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(8000),
+            Amount::from(2),
+            &keyset_fees,
+            false,
+            true,
+        )
+        .unwrap();
+
+        assert_eq!(result.proofs_to_send.len(), 10);
+        assert!(result.proofs_to_swap.is_empty());
+    }
+
+    #[test]
+    fn test_split_exact_match_powers_of_two() {
+        let input_proofs = proofs(&[4096, 512, 256, 128, 8]);
+        let send_amounts = amounts(&[4096, 512, 256, 128, 8]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(5000),
+            Amount::from(1),
+            &keyset_fees,
+            false,
+            true,
+        )
+        .unwrap();
+
+        assert_eq!(result.proofs_to_send.len(), 5);
+        assert!(result.proofs_to_swap.is_empty());
+    }
+
+    // ========================================================================
+    // Swap Required - Partial Match Tests
+    // ========================================================================
+
+    #[test]
+    fn test_split_single_mismatch() {
+        let input_proofs = proofs(&[8, 4, 2, 1]);
+        let send_amounts = amounts(&[8, 2]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(10),
+            Amount::from(1),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        let send_amounts_result: Vec<u64> = result
+            .proofs_to_send
+            .iter()
+            .map(|p| p.amount.into())
+            .collect();
+        let swap_amounts_result: Vec<u64> = result
+            .proofs_to_swap
+            .iter()
+            .map(|p| p.amount.into())
+            .collect();
+
+        assert!(send_amounts_result.contains(&8));
+        assert!(send_amounts_result.contains(&2));
+        assert!(swap_amounts_result.contains(&4) || swap_amounts_result.contains(&1));
+    }
+
+    #[test]
+    fn test_split_multiple_mismatches() {
+        let input_proofs = proofs(&[4096, 1024, 512, 256, 64, 32, 16, 8]);
+        let send_amounts = amounts(&[4096, 512, 256, 128, 8]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(5000),
+            Amount::from(1),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        let send_amounts_result: Vec<u64> = result
+            .proofs_to_send
+            .iter()
+            .map(|p| p.amount.into())
+            .collect();
+
+        // 4096, 512, 256, 8 should match; 128 not in input, 1024, 64, 32, 16 to swap
+        assert!(send_amounts_result.contains(&4096));
+        assert!(send_amounts_result.contains(&512));
+        assert!(send_amounts_result.contains(&256));
+        assert!(send_amounts_result.contains(&8));
+        assert!(!result.proofs_to_swap.is_empty());
+    }
+
+    #[test]
+    fn test_split_half_match() {
+        let input_proofs = proofs(&[2048, 2048, 1024, 512, 256, 128, 64, 32]);
+        let send_amounts = amounts(&[4096, 512, 256, 128, 8]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(5000),
+            Amount::from(1),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        let send_amounts_result: Vec<u64> = result
+            .proofs_to_send
+            .iter()
+            .map(|p| p.amount.into())
+            .collect();
+
+        // Only 512, 256, 128 should match (no 4096 or 8 in input)
+        assert!(send_amounts_result.contains(&512));
+        assert!(send_amounts_result.contains(&256));
+        assert!(send_amounts_result.contains(&128));
+        assert!(!result.proofs_to_swap.is_empty());
+    }
+
+    #[test]
+    fn test_split_large_swap_set() {
+        let input_proofs = proofs(&[1024, 1024, 1024, 1024, 1024, 512, 256, 128, 64, 32, 16, 8]);
+        let send_amounts = amounts(&[4096, 512, 256, 128, 8]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(5000),
+            Amount::from(1),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        let send_amounts_result: Vec<u64> = result
+            .proofs_to_send
+            .iter()
+            .map(|p| p.amount.into())
+            .collect();
+
+        assert!(send_amounts_result.contains(&512));
+        assert!(send_amounts_result.contains(&256));
+        assert!(send_amounts_result.contains(&128));
+        assert!(send_amounts_result.contains(&8));
+        // All 1024s and 64, 32, 16 should be in swap
+        assert!(result.proofs_to_swap.len() >= 5);
+    }
+
+    #[test]
+    fn test_split_dense_small_proofs() {
+        let input_proofs = proofs(&[
+            512, 256, 256, 128, 128, 128, 64, 64, 64, 64, 32, 32, 16, 16, 8, 8, 4, 4, 2, 2,
+        ]);
+        let send_amounts = amounts(&[1024, 256, 128, 64, 16, 8, 4]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(1500),
+            Amount::from(2),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // No 1024 in input, so swap needed
+        assert!(!result.proofs_to_swap.is_empty());
+        // Should have matched some proofs
+        let send_amounts_result: Vec<u64> = result
+            .proofs_to_send
+            .iter()
+            .map(|p| p.amount.into())
+            .collect();
+        assert!(
+            send_amounts_result.contains(&256)
+                || send_amounts_result.contains(&128)
+                || send_amounts_result.contains(&64)
+        );
+    }
+
+    // ========================================================================
+    // Swap Required - No Match Tests
+    // ========================================================================
+
+    #[test]
+    fn test_split_fragmented_no_match() {
+        // 64×10, 32×5, 16×10, 8×5 = 640 + 160 + 160 + 40 = 1000
+        let mut input_amounts = vec![];
+        for _ in 0..10 {
+            input_amounts.push(64);
+        }
+        for _ in 0..5 {
+            input_amounts.push(32);
+        }
+        for _ in 0..10 {
+            input_amounts.push(16);
+        }
+        for _ in 0..5 {
+            input_amounts.push(8);
+        }
+        let input_proofs = proofs(&input_amounts);
+        let send_amounts = amounts(&[512, 256, 128, 64, 32, 8]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(1000),
+            Amount::from(2),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // Some proofs should match (64, 32, 8 exist in input)
+        let send_amounts_result: Vec<u64> = result
+            .proofs_to_send
+            .iter()
+            .map(|p| p.amount.into())
+            .collect();
+        // 512, 256, 128 don't exist so need swap
+        assert!(!result.proofs_to_swap.is_empty());
+        // But 64, 32, 8 should be in send
+        assert!(
+            send_amounts_result.contains(&64)
+                || send_amounts_result.contains(&32)
+                || send_amounts_result.contains(&8)
+        );
+    }
+
+    #[test]
+    fn test_split_large_fragmented() {
+        // 256×8, 128×4, 64×8, 32×4, 16×8, 8×4 = 2048 + 512 + 512 + 128 + 128 + 32 = 3360
+        let mut input_amounts = vec![];
+        for _ in 0..8 {
+            input_amounts.push(256);
+        }
+        for _ in 0..4 {
+            input_amounts.push(128);
+        }
+        for _ in 0..8 {
+            input_amounts.push(64);
+        }
+        for _ in 0..4 {
+            input_amounts.push(32);
+        }
+        for _ in 0..8 {
+            input_amounts.push(16);
+        }
+        for _ in 0..4 {
+            input_amounts.push(8);
+        }
+        let input_proofs = proofs(&input_amounts);
+        // Total = 8*256 + 4*128 + 8*64 + 4*32 + 8*16 + 4*8 = 2048+512+512+128+128+32 = 3360
+        // Use send_amounts that DON'T all exist in input to force swap
+        let send_amounts = amounts(&[512, 256, 128, 64, 32, 8]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(1000),
+            Amount::from(2),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // 256, 128, 64, 32, 8 exist in input but 512 doesn't
+        // proofs_to_send = [256, 128, 64, 32, 8] = 488
+        // swap_output_needed = (1000 + 2) - 488 = 514
+        let send_amounts_result: Vec<u64> = result
+            .proofs_to_send
+            .iter()
+            .map(|p| p.amount.into())
+            .collect();
+        assert!(
+            send_amounts_result.contains(&256)
+                || send_amounts_result.contains(&128)
+                || send_amounts_result.contains(&32)
+        );
+        // Most proofs need swapping since we need to produce 514 from swap
+        assert!(result.proofs_to_swap.len() > 10);
+    }
+
+    // ========================================================================
+    // Swap Fee Adjustment Tests
+    // ========================================================================
+
+    #[test]
+    fn test_split_swap_sufficient() {
+        let input_proofs = proofs(&[4096, 512, 256, 128, 8, 64, 32]);
+        let send_amounts = amounts(&[4096, 512, 256, 128, 8]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(5000),
+            Amount::from(1),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // 64, 32 go to swap (96 total), fee = 1, can produce 95 >= 0 needed
+        let swap_amounts: Vec<u64> = result
+            .proofs_to_swap
+            .iter()
+            .map(|p| p.amount.into())
+            .collect();
+        assert!(swap_amounts.contains(&64) || swap_amounts.contains(&32));
+    }
+
+    #[test]
+    fn test_split_swap_barely_sufficient() {
+        // Test where proofs_to_send doesn't fully cover amount+fee, requiring swap
+        let input_proofs = proofs(&[2048, 1024, 256, 128, 32, 16, 8, 4, 2, 1]);
+        // Note: removed 64 from input, so send_amounts won't fully match
+        let send_amounts = amounts(&[2048, 1024, 256, 128, 64]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(3520),
+            Amount::from(1),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // proofs_to_send = [2048, 1024, 256, 128] = 3456 (no 64 in input)
+        // swap_output_needed = (3520 + 1) - 3456 = 65
+        // proofs_to_swap = [32, 16, 8, 4, 2, 1] = 63, fee = 2, can produce 61 < 65
+        // So swap needs more proofs moved from send
+        assert!(!result.proofs_to_swap.is_empty());
+
+        let swap_total: u64 = result
+            .proofs_to_swap
+            .iter()
+            .map(|p| u64::from(p.amount))
+            .sum();
+        let swap_fee: u64 = result.swap_fee.into();
+        assert!(swap_total - swap_fee >= 65);
+    }
+
+    #[test]
+    fn test_split_move_one_proof() {
+        // Scenario: to_send has [4096, 512, 256, 128, 64, 32], to_swap has [16, 8]
+        // swap_output_needed = 50, swap can produce 24-1=23 < 50
+        // Need to move 32 to swap: 24+32=56, fee=1, can produce 55 >= 50
+        let input_proofs = proofs(&[4096, 512, 256, 128, 64, 32, 16, 8]);
+        let send_amounts = amounts(&[4096, 512, 256, 128, 64, 32]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        // We need swap to produce 50 sats
+        // send = 4096+512+256+128+64+32 = 5088, amount+fee = 5088+50 = 5138
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(5088),
+            Amount::from(50),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // Should have moved 32 (smallest) from send to swap
+        let swap_total: u64 = result
+            .proofs_to_swap
+            .iter()
+            .map(|p| u64::from(p.amount))
+            .sum();
+        // 16 + 8 + 32 = 56, or some variation
+        assert!(swap_total >= 50);
+    }
+
+    #[test]
+    fn test_split_move_multiple_proofs() {
+        let input_proofs = proofs(&[2048, 1024, 512, 256, 128, 64, 8, 4, 2, 1]);
+        let send_amounts = amounts(&[2048, 1024, 512, 256, 128, 64]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        // swap has [8,4,2,1] = 15, need output of 100
+        // fee = 1, can produce 14 < 100
+        // Need to move proofs
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(4032),
+            Amount::from(100),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        let swap_total: u64 = result
+            .proofs_to_swap
+            .iter()
+            .map(|p| u64::from(p.amount))
+            .sum();
+        let swap_fee: u64 = result.swap_fee.into();
+        // Should have moved enough to cover 100
+        assert!(swap_total - swap_fee >= 100);
+    }
+
+    #[test]
+    fn test_split_high_fee_many_proofs() {
+        let input_proofs = proofs(&[1024, 512, 256, 128, 64, 32, 16, 8, 4, 4, 2, 2, 1, 1, 1, 1]);
+        let send_amounts = amounts(&[1024, 512, 256, 128, 64, 32, 16, 8]);
+        let keyset_fees = keyset_fees_with_ppk(1000); // 1 sat per proof
+
+        // swap has [4,4,2,2,1,1,1,1] = 16, 8 proofs, fee = 8, can produce 8
+        // Need to produce 10
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(2040),
+            Amount::from(10),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        let swap_total: u64 = result
+            .proofs_to_swap
+            .iter()
+            .map(|p| u64::from(p.amount))
+            .sum();
+        let swap_fee: u64 = result.swap_fee.into();
+        assert!(swap_total - swap_fee >= 10);
+    }
+
+    #[test]
+    fn test_split_fee_eats_small_proofs() {
+        let input_proofs = proofs(&[4096, 512, 256, 128, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]);
+        let send_amounts = amounts(&[4096, 512, 256, 128]);
+        let keyset_fees = keyset_fees_with_ppk(1000); // 1 sat per proof
+
+        // swap has 10×1 = 10, fee = 10, can produce 0
+        // Need to produce 5
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(4992),
+            Amount::from(5),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        let swap_total: u64 = result
+            .proofs_to_swap
+            .iter()
+            .map(|p| u64::from(p.amount))
+            .sum();
+        let swap_fee: u64 = result.swap_fee.into();
+        // Must have moved a larger proof (128) to swap
+        assert!(swap_total - swap_fee >= 5);
+        assert!(swap_total > 10); // More than just the 1s
+    }
+
+    #[test]
+    fn test_split_cascading_fee_increase() {
+        let input_proofs = proofs(&[2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1]);
+        let send_amounts = amounts(&[2048, 1024, 512, 256, 128, 64]);
+        let keyset_fees = keyset_fees_with_ppk(500); // 0.5 sat per proof
+
+        // swap has [32,16,8,4,2,1] = 63, 6 proofs, fee = 3, can produce 60
+        // Need 80
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(4032),
+            Amount::from(80),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        let swap_total: u64 = result
+            .proofs_to_swap
+            .iter()
+            .map(|p| u64::from(p.amount))
+            .sum();
+        let swap_fee: u64 = result.swap_fee.into();
+        assert!(swap_total - swap_fee >= 80);
+    }
+
+    // ========================================================================
+    // Complex Scenarios with Many Proofs
+    // ========================================================================
+
+    #[test]
+    fn test_split_20_proofs_mixed() {
+        // [2048, 1024, 512, 256×2, 128×2, 64×4, 32×4, 16×4]
+        // Count: 1 + 1 + 1 + 2 + 2 + 4 + 4 + 4 = 19 proofs. Need one more for 20.
+        let mut input_amounts = vec![2048, 1024, 512];
+        input_amounts.extend(vec![256; 2]);
+        input_amounts.extend(vec![128; 2]);
+        input_amounts.extend(vec![64; 4]);
+        input_amounts.extend(vec![32; 4]);
+        input_amounts.extend(vec![16; 4]);
+        input_amounts.push(8); // Add one more to make 20
+        let input_proofs = proofs(&input_amounts);
+        // Use send amounts that match proofs in input
+        let send_amounts = amounts(&[2048, 1024, 512, 256, 128]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(3968), // 2048+1024+512+256+128 = 3968
+            Amount::from(1),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // All send_amounts exist in input
+        let send_amounts_result: Vec<u64> = result
+            .proofs_to_send
+            .iter()
+            .map(|p| p.amount.into())
+            .collect();
+        // Check some proofs went to send
+        assert!(
+            send_amounts_result.contains(&2048)
+                || send_amounts_result.contains(&1024)
+                || send_amounts_result.contains(&512)
+        );
+        // Some proofs to swap (the extras)
+        assert!(!result.proofs_to_swap.is_empty());
+        // Total proofs preserved
+        assert_eq!(
+            result.proofs_to_send.len() + result.proofs_to_swap.len(),
+            20
+        );
+    }
+
+    #[test]
+    fn test_split_30_small_proofs() {
+        // [256×2, 128×4, 64×6, 32×6, 16×6, 8×6]
+        let mut input_amounts = vec![];
+        input_amounts.extend(vec![256; 2]);
+        input_amounts.extend(vec![128; 4]);
+        input_amounts.extend(vec![64; 6]);
+        input_amounts.extend(vec![32; 6]);
+        input_amounts.extend(vec![16; 6]);
+        input_amounts.extend(vec![8; 6]);
+        let input_proofs = proofs(&input_amounts);
+        let send_amounts = amounts(&[1024, 512, 256, 128, 64, 8]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(2000),
+            Amount::from(6), // 30 proofs = 6 sat fee @ 200ppk
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        assert_eq!(
+            result.proofs_to_send.len() + result.proofs_to_swap.len(),
+            30
+        );
+    }
+
+    #[test]
+    fn test_split_15_proofs_high_fee() {
+        // [4096, 1024×2, 512×2, 256×2, 128×2, 64×2, 32×2, 16×2]
+        let mut input_amounts = vec![4096];
+        input_amounts.extend(vec![1024; 2]);
+        input_amounts.extend(vec![512; 2]);
+        input_amounts.extend(vec![256; 2]);
+        input_amounts.extend(vec![128; 2]);
+        input_amounts.extend(vec![64; 2]);
+        input_amounts.extend(vec![32; 2]);
+        input_amounts.extend(vec![16; 2]);
+        let input_proofs = proofs(&input_amounts);
+        let send_amounts = amounts(&[4096, 2048, 1024, 512, 256, 64]);
+        let keyset_fees = keyset_fees_with_ppk(500);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(8000),
+            Amount::from(8), // 15 proofs = 8 sat fee @ 500ppk
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        assert_eq!(
+            result.proofs_to_send.len() + result.proofs_to_swap.len(),
+            15
+        );
+    }
+
+    #[test]
+    fn test_split_uniform_25_proofs() {
+        let input_proofs = proofs(&[256; 25]);
+        let send_amounts = amounts(&[4096, 512, 256, 128, 8]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(5000),
+            Amount::from(1),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // Only one 256 matches
+        let send_count = result.proofs_to_send.len();
+        let swap_count = result.proofs_to_swap.len();
+        assert_eq!(send_count + swap_count, 25);
+        assert_eq!(send_count, 1); // Only one 256 matches
+    }
+
+    #[test]
+    fn test_split_tiered_18_proofs() {
+        // [4096, 2048, 1024×2, 512×2, 256×4, 128×4, 64×4]
+        let mut input_amounts = vec![4096, 2048];
+        input_amounts.extend(vec![1024; 2]);
+        input_amounts.extend(vec![512; 2]);
+        input_amounts.extend(vec![256; 4]);
+        input_amounts.extend(vec![128; 4]);
+        input_amounts.extend(vec![64; 4]);
+        let input_proofs = proofs(&input_amounts);
+        let send_amounts = amounts(&[8192, 1024, 512, 256, 8]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(10000),
+            Amount::from(4), // 18 proofs = 4 sat fee @ 200ppk
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        assert_eq!(
+            result.proofs_to_send.len() + result.proofs_to_swap.len(),
+            18
+        );
+    }
+
+    #[test]
+    fn test_split_dust_consolidation() {
+        // [16×50, 8×50, 4×50, 2×50, 1×50] = 250 proofs
+        let mut input_amounts = vec![];
+        input_amounts.extend(vec![16; 50]);
+        input_amounts.extend(vec![8; 50]);
+        input_amounts.extend(vec![4; 50]);
+        input_amounts.extend(vec![2; 50]);
+        input_amounts.extend(vec![1; 50]);
+        let input_proofs = proofs(&input_amounts);
+        let send_amounts = amounts(&[1024, 256, 128, 64, 16, 8, 4]);
+        let keyset_fees = keyset_fees_with_ppk(100);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(1500),
+            Amount::from(25), // 250 proofs = 25 sat fee @ 100ppk
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // 16, 8, 4 exist and match
+        let send_amounts_result: Vec<u64> = result
+            .proofs_to_send
+            .iter()
+            .map(|p| p.amount.into())
+            .collect();
+        assert!(
+            send_amounts_result.contains(&16)
+                || send_amounts_result.contains(&8)
+                || send_amounts_result.contains(&4)
+        );
+    }
+
+    // ========================================================================
+    // Force Swap Scenarios
+    // ========================================================================
+
+    #[test]
+    fn test_split_force_swap_8_proofs() {
+        let input_proofs = proofs(&[2048, 1024, 512, 256, 128, 64, 32, 16]);
+        let send_amounts = amounts(&[2048, 1024, 512, 256, 128, 32]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(3000),
+            Amount::from(2),
+            &keyset_fees,
+            true, // force_swap
+            false,
+        )
+        .unwrap();
+
+        assert!(result.proofs_to_send.is_empty());
+        assert_eq!(result.proofs_to_swap.len(), 8);
+    }
+
+    #[test]
+    fn test_split_force_swap_15_proofs() {
+        let mut input_amounts = vec![];
+        input_amounts.extend(vec![1024; 5]);
+        input_amounts.extend(vec![512; 5]);
+        input_amounts.extend(vec![256; 5]);
+        let input_proofs = proofs(&input_amounts);
+        let send_amounts = amounts(&[8000]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(8000),
+            Amount::from(3),
+            &keyset_fees,
+            true, // force_swap
+            false,
+        )
+        .unwrap();
+
+        assert!(result.proofs_to_send.is_empty());
+        assert_eq!(result.proofs_to_swap.len(), 15);
+    }
+
+    #[test]
+    fn test_split_force_swap_fragmented() {
+        // 64×10, 32×10, 16×10, 8×10 = 40 proofs
+        let mut input_amounts = vec![];
+        input_amounts.extend(vec![64; 10]);
+        input_amounts.extend(vec![32; 10]);
+        input_amounts.extend(vec![16; 10]);
+        input_amounts.extend(vec![8; 10]);
+        let input_proofs = proofs(&input_amounts);
+        let send_amounts = amounts(&[2000]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(2000),
+            Amount::from(8),
+            &keyset_fees,
+            true, // force_swap
+            false,
+        )
+        .unwrap();
+
+        assert!(result.proofs_to_send.is_empty());
+        assert_eq!(result.proofs_to_swap.len(), 40);
+    }
+
+    // ========================================================================
+    // Edge Cases
+    // ========================================================================
+
+    #[test]
+    fn test_split_single_large_proof() {
+        let input_proofs = proofs(&[8192]);
+        let send_amounts = amounts(&[4096, 2048, 1024, 512, 256, 64]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(8000),
+            Amount::from(1),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // 8192 doesn't match any send amount, goes to swap
+        assert!(result.proofs_to_send.is_empty());
+        assert_eq!(result.proofs_to_swap.len(), 1);
+    }
+
+    #[test]
+    fn test_split_many_1sat_proofs() {
+        let input_proofs = proofs(&[1; 100]);
+        let send_amounts = amounts(&[32, 16, 2]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(50),
+            Amount::from(20), // 100 proofs = 20 sat fee @ 200ppk
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // No proofs match (no 32, 16, or 2 individual proofs)
+        assert!(result.proofs_to_send.is_empty());
+        assert_eq!(result.proofs_to_swap.len(), 100);
+    }
+
+    #[test]
+    fn test_split_all_same_denomination() {
+        let input_proofs = proofs(&[512; 10]);
+        let send_amounts = amounts(&[4096, 512, 256, 128, 8]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(4000),
+            Amount::from(2),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // Only one 512 matches
+        let send_count = result.proofs_to_send.len();
+        assert_eq!(send_count, 1);
+        assert_eq!(result.proofs_to_swap.len(), 9);
+    }
+
+    #[test]
+    fn test_split_alternating_sizes() {
+        let input_proofs = proofs(&[1024, 64, 1024, 64, 1024, 64, 1024, 64]);
+        let send_amounts = amounts(&[4096, 256, 128]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(4000),
+            Amount::from(2),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // No proofs match exactly
+        assert!(result.proofs_to_send.is_empty());
+        assert_eq!(result.proofs_to_swap.len(), 8);
+    }
+
+    #[test]
+    fn test_split_power_of_two_boundary() {
+        let input_proofs = proofs(&[2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1]);
+        let send_amounts = amounts(&[2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(4095),
+            Amount::from(3), // 12 proofs = 3 sat fee @ 200ppk
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // All proofs match
+        assert_eq!(result.proofs_to_send.len(), 12);
+        assert!(result.proofs_to_swap.is_empty());
+    }
+
+    #[test]
+    fn test_split_just_over_boundary() {
+        // Total = 2048+1024+512+256+128+64+32+16+8+4+2+1+1 = 4096
+        // With an extra proof to give some buffer for fees
+        let input_proofs = proofs(&[2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1, 1, 64]);
+        // Total now = 4160
+        let send_amounts = amounts(&[2048, 1024, 512, 1]);
+        let keyset_fees = keyset_fees_with_ppk(200);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(3585), // 2048+1024+512+1 = 3585
+            Amount::from(3),    // 14 proofs = 3 sat fee @ 200ppk
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // 2048, 1024, 512, 1 match
+        let send_amounts_result: Vec<u64> = result
+            .proofs_to_send
+            .iter()
+            .map(|p| p.amount.into())
+            .collect();
+        assert!(send_amounts_result.contains(&1) || send_amounts_result.contains(&2048));
+        // Some proofs go to swap
+        assert!(!result.proofs_to_swap.is_empty());
+        // Total proofs preserved
+        assert_eq!(
+            result.proofs_to_send.len() + result.proofs_to_swap.len(),
+            14
+        );
+    }
+
+    // ========================================================================
+    // Regression Tests
+    // ========================================================================
+
+    #[test]
+    fn test_split_regression_insufficient_swap_fee() {
+        // Scenario where initial swap proofs can't cover their own fee
+        let input_proofs = proofs(&[4096, 512, 256, 128, 1, 1]);
+        let send_amounts = amounts(&[4096, 512, 256, 128]);
+        let keyset_fees = keyset_fees_with_ppk(1000); // 1 sat per proof
+
+        // swap has [1,1] = 2, fee = 2, can produce 0
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(4992),
+            Amount::from(1),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // Should have moved proofs to make swap viable
+        let swap_total: u64 = result
+            .proofs_to_swap
+            .iter()
+            .map(|p| u64::from(p.amount))
+            .sum();
+        let swap_fee: u64 = result.swap_fee.into();
+        // Must be able to produce at least 1
+        assert!(swap_total > swap_fee || result.proofs_to_swap.is_empty());
+    }
+
+    #[test]
+    fn test_split_regression_many_small_in_swap() {
+        // Many small proofs in swap that individually have high fee overhead
+        let mut input_amounts = vec![4096, 1024];
+        input_amounts.extend(vec![1; 20]);
+        let input_proofs = proofs(&input_amounts);
+        let send_amounts = amounts(&[4096, 1024]);
+        let keyset_fees = keyset_fees_with_ppk(500);
+
+        // swap has 20×1 = 20, fee = 10, can produce 10
+        // Need to produce something for change
+        let result = split_proofs_for_send(
+            input_proofs,
+            &send_amounts,
+            Amount::from(5120),
+            Amount::from(5),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // Should handle this gracefully
+        assert!(result.proofs_to_send.len() + result.proofs_to_swap.len() == 22);
+    }
+}

+ 0 - 10
crates/cdk/src/wallet/swap.rs

@@ -93,16 +93,6 @@ impl Wallet {
                     }
                 };
 
-                let send_amount = proofs_to_send.total_amount()?;
-
-                if send_amount.ne(&(amount + pre_swap.fee)) {
-                    tracing::warn!(
-                        "Send amount proofs is {:?} expected {:?}",
-                        send_amount,
-                        amount
-                    );
-                }
-
                 let send_proofs_info = proofs_to_send
                     .clone()
                     .into_iter()