//! Wallet Saga Integration Tests //! //! These tests verify saga-specific behavior that isn't covered by other integration tests: //! - Proof reservation and isolation //! - Cancellation/compensation flows //! - Concurrent saga isolation //! //! Basic happy-path flows are covered by other integration tests (fake_wallet.rs, //! integration_tests_pure.rs, etc.) use anyhow::Result; use cashu::{MeltQuoteState, PaymentMethod}; use cdk::nuts::nut00::ProofsMethods; use cdk::wallet::SendOptions; use cdk::Amount; use cdk_fake_wallet::create_fake_invoice; use cdk_integration_tests::init_pure_tests::*; // ============================================================================= // Saga-Specific Tests // ============================================================================= /// Tests that cancelling a prepared send releases proofs back to Unspent #[tokio::test] async fn test_send_cancel_releases_proofs() -> Result<()> { setup_tracing(); let mint = create_and_start_test_mint().await?; let wallet = create_test_wallet_for_mint(mint.clone()).await?; // Fund wallet let initial_amount = Amount::from(1000); fund_wallet(wallet.clone(), initial_amount.into(), None).await?; let send_amount = Amount::from(400); // Prepare send let prepared = wallet .prepare_send(send_amount, SendOptions::default()) .await?; // Verify proofs are reserved let reserved_before = wallet.get_reserved_proofs().await?; assert!(!reserved_before.is_empty()); // Cancel the prepared send prepared.cancel().await?; // Verify proofs are released (no longer reserved) let reserved_after = wallet.get_reserved_proofs().await?; assert!(reserved_after.is_empty()); // Verify full balance is restored let balance = wallet.total_balance().await?; assert_eq!(balance, initial_amount); Ok(()) } /// Tests that proofs reserved by prepare_send cannot be used by another send #[tokio::test] async fn test_reserved_proofs_excluded_from_selection() -> Result<()> { setup_tracing(); let mint = create_and_start_test_mint().await?; let wallet = create_test_wallet_for_mint(mint.clone()).await?; // Fund wallet with exact amount for two sends fund_wallet(wallet.clone(), 600, None).await?; // First prepare reserves some proofs let prepared1 = wallet .prepare_send(Amount::from(300), SendOptions::default()) .await?; // Second prepare should still work (different proofs) let prepared2 = wallet .prepare_send(Amount::from(300), SendOptions::default()) .await?; // Both should have disjoint proofs let ys1: std::collections::HashSet<_> = prepared1.proofs().ys()?.into_iter().collect(); let ys2: std::collections::HashSet<_> = prepared2.proofs().ys()?.into_iter().collect(); assert!(ys1.is_disjoint(&ys2)); // Third prepare should fail (all proofs reserved) let result = wallet .prepare_send(Amount::from(100), SendOptions::default()) .await; assert!(result.is_err()); // Cancel first, now we should be able to prepare again prepared1.cancel().await?; let prepared3 = wallet .prepare_send(Amount::from(100), SendOptions::default()) .await; assert!(prepared3.is_ok()); Ok(()) } /// Tests that multiple concurrent send sagas don't interfere with each other #[tokio::test] async fn test_concurrent_sends_isolated() -> Result<()> { setup_tracing(); let mint = create_and_start_test_mint().await?; let wallet = create_test_wallet_for_mint(mint.clone()).await?; // Fund wallet let initial_amount = Amount::from(2000); fund_wallet(wallet.clone(), initial_amount.into(), None).await?; // Prepare two sends concurrently let wallet1 = wallet.clone(); let wallet2 = wallet.clone(); let (prepared1, prepared2) = tokio::join!( wallet1.prepare_send(Amount::from(300), SendOptions::default()), wallet2.prepare_send(Amount::from(400), SendOptions::default()) ); let prepared1 = prepared1?; let prepared2 = prepared2?; // Verify both have reserved proofs (should be different proofs) let reserved1 = prepared1.proofs(); let reserved2 = prepared2.proofs(); // The proofs should not overlap let ys1: std::collections::HashSet<_> = reserved1.ys()?.into_iter().collect(); let ys2: std::collections::HashSet<_> = reserved2.ys()?.into_iter().collect(); assert!(ys1.is_disjoint(&ys2)); // Confirm both let (token1, token2) = tokio::join!(prepared1.confirm(None), prepared2.confirm(None)); let _token1 = token1?; let _token2 = token2?; // Verify final balance is correct let final_balance = wallet.total_balance().await?; assert_eq!(final_balance, initial_amount - Amount::from(700)); Ok(()) } /// Tests concurrent melt operations are isolated #[tokio::test] async fn test_concurrent_melts_isolated() -> Result<()> { setup_tracing(); let mint = create_and_start_test_mint().await?; let wallet = create_test_wallet_for_mint(mint.clone()).await?; // Fund wallet with enough for multiple melts fund_wallet(wallet.clone(), 2000, None).await?; // Create two invoices let invoice1 = create_fake_invoice(200_000, "melt 1".to_string()); let invoice2 = create_fake_invoice(300_000, "melt 2".to_string()); // Get quotes let quote1 = wallet .melt_quote(PaymentMethod::BOLT11, invoice1.to_string(), None, None) .await?; let quote2 = wallet .melt_quote(PaymentMethod::BOLT11, invoice2.to_string(), None, None) .await?; // Execute both melts concurrently let wallet1 = wallet.clone(); let wallet2 = wallet.clone(); let quote_id1 = quote1.id.clone(); let quote_id2 = quote2.id.clone(); // Prepare both melts let prepared1 = wallet1 .prepare_melt("e_id1, std::collections::HashMap::new()) .await?; let prepared2 = wallet2 .prepare_melt("e_id2, std::collections::HashMap::new()) .await?; // Confirm both in parallel let (result1, result2) = tokio::join!(prepared1.confirm(), prepared2.confirm()); // Both should succeed let confirmed1 = result1?; let confirmed2 = result2?; assert_eq!(confirmed1.state(), MeltQuoteState::Paid); assert_eq!(confirmed2.state(), MeltQuoteState::Paid); // Verify total amount melted let final_balance = wallet.total_balance().await?; assert!(final_balance < Amount::from(1500)); // At least 500 melted Ok(()) } // ============================================================================= // Melt Saga Input Fee Tests // ============================================================================= /// Tests that melt saga correctly includes input fees when calculating total needed. /// /// This is a regression test for a bug where confirm_melt calculated: /// inputs_needed_amount = quote.amount + fee_reserve /// but should calculate: /// inputs_needed_amount = quote.amount + fee_reserve + input_fee /// /// The bug manifested as: "not enough inputs provided for melt. Provided: X, needed: X+1" /// /// Scenario: /// - Mint with 1000 ppk (1 sat per proof input fee) /// - Melt for 26 sats /// - fee_reserve = 2 sats /// - If wallet has proofs that don't exactly match, it swaps first /// - The swap produces proofs totaling (amount + fee_reserve) = 28 sats /// - But mint actually needs (amount + fee_reserve + input_fee) = 29 sats /// /// Before fix: Melt fails with "not enough inputs provided for melt" /// After fix: Melt succeeds #[tokio::test] async fn test_melt_saga_includes_input_fees() -> Result<()> { use cdk::nuts::CurrencyUnit; setup_tracing(); let mint = create_and_start_test_mint().await?; let wallet = create_test_wallet_for_mint(mint.clone()).await?; // Rotate to keyset with 1000 ppk = 1 sat per proof fee // This is required to trigger the bug - without input fees, the calculation is correct mint.rotate_keyset( CurrencyUnit::Sat, cdk_integration_tests::standard_keyset_amounts(32), 1000, // 1 sat per proof input fee true, ) .await .expect("Failed to rotate keyset"); // Brief pause to ensure keyset rotation is complete tokio::time::sleep(std::time::Duration::from_millis(100)).await; // Fund wallet with enough to cover melt amount + fee_reserve + input fees // Use larger amounts to ensure there are enough proofs of the right denominations let initial_amount = 500u64; fund_wallet(wallet.clone(), initial_amount, None).await?; let initial_balance = wallet.total_balance().await?; assert_eq!(initial_balance, Amount::from(initial_amount)); // Create melt quote for an amount that requires a swap // 100 sats = 100000 msats // fee_reserve should be ~2 sats (2% of 100) // inputs_needed without input_fee = 102 sats // With input_fee (depends on proof count), mint needs more let invoice = create_fake_invoice(100_000, "test melt with fees".to_string()); let melt_quote = wallet .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None) .await?; tracing::info!( "Melt quote: amount={}, fee_reserve={}", melt_quote.amount, melt_quote.fee_reserve ); // Perform the melt - this should succeed even with input fees // Before the fix, this would fail with: // "not enough inputs provided for melt. Provided: X, needed: X+1" let prepared = wallet .prepare_melt(&melt_quote.id, std::collections::HashMap::new()) .await?; let confirmed = prepared.confirm().await?; assert_eq!(confirmed.state(), MeltQuoteState::Paid); tracing::info!( "Melt succeeded: amount={}, fee_paid={}", confirmed.amount(), confirmed.fee_paid() ); // Verify final balance makes sense let final_balance = wallet.total_balance().await?; assert!( final_balance < initial_balance, "Balance should decrease after melt" ); Ok(()) } /// Regression test: Melt with swap should account for actual output proof count. /// /// This test reproduces a bug where: /// 1. Wallet has many small proofs (non-optimal denominations) /// 2. User tries to melt an amount that requires a swap /// 3. The swap produces more proofs than the "optimal" estimate /// 4. The actual input_fee is higher than estimated /// 5. Result: "Insufficient funds" even though wallet has enough balance /// /// The issue was that `estimated_melt_fee` was based on `inputs_needed_amount.split()` /// but after swap with `amount=None`, the actual proof count could be higher, /// leading to a higher `actual_input_fee`. /// /// Example from real failure: /// - inputs_needed_amount = 6700 (optimal split = 7 proofs, fee = 1) /// - selection_amount = 6701 /// - Selected 12 proofs totaling 6703, swap_fee = 2 /// - After swap: 6701 worth but 13 proofs (not optimal 7!) /// - actual_input_fee = 2 (not 1!) /// - Need: 6633 + 67 + 2 = 6702, Have: 6701 → Insufficient funds! #[tokio::test] async fn test_melt_with_swap_non_optimal_proofs() -> Result<()> { use cdk::amount::SplitTarget; use cdk::nuts::CurrencyUnit; setup_tracing(); let mint = create_and_start_test_mint().await?; let wallet = create_test_wallet_for_mint(mint.clone()).await?; // Use a keyset with 100 ppk (0.1 sat per proof, so ~10 proofs = 1 sat fee) // This makes the fee difference noticeable when proof count differs mint.rotate_keyset( CurrencyUnit::Sat, cdk_integration_tests::standard_keyset_amounts(32), 100, // 0.1 sat per proof input fee true, ) .await .expect("Failed to rotate keyset"); tokio::time::sleep(std::time::Duration::from_millis(100)).await; // Fund wallet with many 1-sat proofs (very non-optimal) // This forces a swap when trying to melt, and the swap output // may have more proofs than the "optimal" estimate let initial_amount = 200u64; fund_wallet( wallet.clone(), initial_amount, Some(SplitTarget::Value(Amount::ONE)), ) .await?; let initial_balance = wallet.total_balance().await?; assert_eq!(initial_balance, Amount::from(initial_amount)); // Verify we have many small proofs let proofs = wallet.get_unspent_proofs().await?; tracing::info!("Funded with {} proofs", proofs.len()); assert!( proofs.len() > 50, "Should have many small proofs to force non-optimal swap" ); // Create melt quote - amount chosen to require a swap // With 200 sats in 1-sat proofs, melting 100 sats should require swapping let invoice = create_fake_invoice(100_000, "test melt with non-optimal proofs".to_string()); let melt_quote = wallet .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None) .await?; tracing::info!( "Melt quote: amount={}, fee_reserve={}", melt_quote.amount, melt_quote.fee_reserve ); // This melt should succeed even with non-optimal proofs // Before fix: fails with "Insufficient funds" because actual_input_fee > estimated let prepared = wallet .prepare_melt(&melt_quote.id, std::collections::HashMap::new()) .await?; let confirmed = prepared.confirm().await?; assert_eq!(confirmed.state(), MeltQuoteState::Paid); tracing::info!( "Melt succeeded: amount={}, fee_paid={}", confirmed.amount(), confirmed.fee_paid() ); // Verify balance decreased appropriately let final_balance = wallet.total_balance().await?; assert!( final_balance < initial_balance, "Balance should decrease after melt" ); Ok(()) } /// Tests recovery when a crash occurs after the swap but before the melt request is persisted. /// /// This simulates the "Swap Gap": /// 1. MeltSaga prepares (ProofsReserved). /// 2. Swap executes (Old proofs spent, New proofs created). /// 3. CRASH (MeltSaga not updated to MeltRequested). /// 4. Recovery runs. /// /// Expected behavior: /// - The recovery should see ProofsReserved. /// - It attempts to revert reservation. /// - Since old proofs are spent (deleted from DB), revert does nothing. /// - Saga is deleted. /// - Wallet contains NEW proofs from the swap. /// - No double counting (Old + New). #[tokio::test] async fn test_melt_swap_gap_recovery() -> Result<()> { use cdk::amount::SplitTarget; use cdk::nuts::CurrencyUnit; setup_tracing(); let mint = create_and_start_test_mint().await?; let wallet = create_test_wallet_for_mint(mint.clone()).await?; // 1. Configure Mint with Input Fees to force a swap // 1000 ppk = 1 sat per proof mint.rotate_keyset( CurrencyUnit::Sat, cdk_integration_tests::standard_keyset_amounts(32), 1000, true, ) .await .expect("Failed to rotate keyset"); tokio::time::sleep(std::time::Duration::from_millis(100)).await; // 2. Fund Wallet with small proofs // 500 sats total in 50-sat proofs. let initial_amount = 500u64; fund_wallet( wallet.clone(), initial_amount, Some(SplitTarget::Value(Amount::from(50))), ) .await?; let initial_balance = wallet.total_balance().await?; assert_eq!(initial_balance, Amount::from(initial_amount)); // 3. Create Melt Quote let invoice = create_fake_invoice(100_000, "test gap".to_string()); let melt_quote = wallet .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None) .await?; // 4. Prepare Melt let prepared = wallet .prepare_melt(&melt_quote.id, std::collections::HashMap::new()) .await?; // Verify we have proofs to swap let proofs_to_swap = prepared.proofs_to_swap(); assert!(!proofs_to_swap.is_empty(), "Should have proofs to swap"); // 5. Simulate the Gap (Manual Swap) // Calculate target amount (what MeltSaga would do) // We only need to swap for the amount + reserve, change will handle the rest. // Including input_fee in target request causes us to request more than we have available // (since input_fee is deducted from inputs). let target_swap_amount = melt_quote.amount + melt_quote.fee_reserve; tracing::info!("Simulating swap for amount: {}", target_swap_amount); // Perform the swap // Note: this consumes the old proofs from the DB and adds new ones. // The `prepared` saga state in memory still points to old proofs, // and the DB saga state is still 'ProofsReserved' with old proofs. let swapped_proofs = wallet .swap( Some(target_swap_amount), SplitTarget::None, proofs_to_swap.clone(), None, false, ) .await?; assert!(swapped_proofs.is_some(), "Swap should succeed"); let swapped_proofs = swapped_proofs.unwrap(); // The swap places the requested amount in 'Reserved' state. // Since we are simulating a crash where these were not consumed, // we need to set them to Unspent to verify the wallet balance is conserved. // In a real scenario, a "stuck reserved proofs" cleanup mechanism would handle this. let ys = swapped_proofs.ys()?; wallet .localstore .update_proofs_state(ys, cdk::nuts::State::Unspent) .await?; // 6. Recover // At this point, the MeltSaga in DB is stale (points to spent proofs). // Recovery should clean it up. let report = wallet.recover_incomplete_sagas().await?; tracing::info!("Recovery report: {:?}", report); // 7. Verify // The saga should be gone/handled. // We check the DB directly to ensure saga is gone. let saga = wallet.localstore.get_saga(&prepared.operation_id()).await?; assert!(saga.is_none(), "Saga should be deleted after recovery"); // Check Balance // We expect: Initial - Swap Fees. // The melt didn't happen (cancelled). // The swap happened. let current_balance = wallet.total_balance().await?; assert!( current_balance < Amount::from(initial_amount), "Balance should have decreased by fee" ); assert!( current_balance > Amount::from(initial_amount) - Amount::from(50), "Fee shouldn't be huge. Initial: {}, Current: {}", initial_amount, current_balance ); Ok(()) }