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