wallet_saga.rs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
  1. //! Wallet Saga Integration Tests
  2. //!
  3. //! These tests verify saga-specific behavior that isn't covered by other integration tests:
  4. //! - Proof reservation and isolation
  5. //! - Cancellation/compensation flows
  6. //! - Concurrent saga isolation
  7. //!
  8. //! Basic happy-path flows are covered by other integration tests (fake_wallet.rs,
  9. //! integration_tests_pure.rs, etc.)
  10. use anyhow::Result;
  11. use cashu::{MeltQuoteState, PaymentMethod};
  12. use cdk::nuts::nut00::ProofsMethods;
  13. use cdk::wallet::SendOptions;
  14. use cdk::Amount;
  15. use cdk_fake_wallet::create_fake_invoice;
  16. use cdk_integration_tests::init_pure_tests::*;
  17. // =============================================================================
  18. // Saga-Specific Tests
  19. // =============================================================================
  20. /// Tests that cancelling a prepared send releases proofs back to Unspent
  21. #[tokio::test]
  22. async fn test_send_cancel_releases_proofs() -> Result<()> {
  23. setup_tracing();
  24. let mint = create_and_start_test_mint().await?;
  25. let wallet = create_test_wallet_for_mint(mint.clone()).await?;
  26. // Fund wallet
  27. let initial_amount = Amount::from(1000);
  28. fund_wallet(wallet.clone(), initial_amount.into(), None).await?;
  29. let send_amount = Amount::from(400);
  30. // Prepare send
  31. let prepared = wallet
  32. .prepare_send(send_amount, SendOptions::default())
  33. .await?;
  34. // Verify proofs are reserved
  35. let reserved_before = wallet.get_reserved_proofs().await?;
  36. assert!(!reserved_before.is_empty());
  37. // Cancel the prepared send
  38. prepared.cancel().await?;
  39. // Verify proofs are released (no longer reserved)
  40. let reserved_after = wallet.get_reserved_proofs().await?;
  41. assert!(reserved_after.is_empty());
  42. // Verify full balance is restored
  43. let balance = wallet.total_balance().await?;
  44. assert_eq!(balance, initial_amount);
  45. Ok(())
  46. }
  47. /// Tests that proofs reserved by prepare_send cannot be used by another send
  48. #[tokio::test]
  49. async fn test_reserved_proofs_excluded_from_selection() -> Result<()> {
  50. setup_tracing();
  51. let mint = create_and_start_test_mint().await?;
  52. let wallet = create_test_wallet_for_mint(mint.clone()).await?;
  53. // Fund wallet with exact amount for two sends
  54. fund_wallet(wallet.clone(), 600, None).await?;
  55. // First prepare reserves some proofs
  56. let prepared1 = wallet
  57. .prepare_send(Amount::from(300), SendOptions::default())
  58. .await?;
  59. // Second prepare should still work (different proofs)
  60. let prepared2 = wallet
  61. .prepare_send(Amount::from(300), SendOptions::default())
  62. .await?;
  63. // Both should have disjoint proofs
  64. let ys1: std::collections::HashSet<_> = prepared1.proofs().ys()?.into_iter().collect();
  65. let ys2: std::collections::HashSet<_> = prepared2.proofs().ys()?.into_iter().collect();
  66. assert!(ys1.is_disjoint(&ys2));
  67. // Third prepare should fail (all proofs reserved)
  68. let result = wallet
  69. .prepare_send(Amount::from(100), SendOptions::default())
  70. .await;
  71. assert!(result.is_err());
  72. // Cancel first, now we should be able to prepare again
  73. prepared1.cancel().await?;
  74. let prepared3 = wallet
  75. .prepare_send(Amount::from(100), SendOptions::default())
  76. .await;
  77. assert!(prepared3.is_ok());
  78. Ok(())
  79. }
  80. /// Tests that multiple concurrent send sagas don't interfere with each other
  81. #[tokio::test]
  82. async fn test_concurrent_sends_isolated() -> Result<()> {
  83. setup_tracing();
  84. let mint = create_and_start_test_mint().await?;
  85. let wallet = create_test_wallet_for_mint(mint.clone()).await?;
  86. // Fund wallet
  87. let initial_amount = Amount::from(2000);
  88. fund_wallet(wallet.clone(), initial_amount.into(), None).await?;
  89. // Prepare two sends concurrently
  90. let wallet1 = wallet.clone();
  91. let wallet2 = wallet.clone();
  92. let (prepared1, prepared2) = tokio::join!(
  93. wallet1.prepare_send(Amount::from(300), SendOptions::default()),
  94. wallet2.prepare_send(Amount::from(400), SendOptions::default())
  95. );
  96. let prepared1 = prepared1?;
  97. let prepared2 = prepared2?;
  98. // Verify both have reserved proofs (should be different proofs)
  99. let reserved1 = prepared1.proofs();
  100. let reserved2 = prepared2.proofs();
  101. // The proofs should not overlap
  102. let ys1: std::collections::HashSet<_> = reserved1.ys()?.into_iter().collect();
  103. let ys2: std::collections::HashSet<_> = reserved2.ys()?.into_iter().collect();
  104. assert!(ys1.is_disjoint(&ys2));
  105. // Confirm both
  106. let (token1, token2) = tokio::join!(prepared1.confirm(None), prepared2.confirm(None));
  107. let _token1 = token1?;
  108. let _token2 = token2?;
  109. // Verify final balance is correct
  110. let final_balance = wallet.total_balance().await?;
  111. assert_eq!(final_balance, initial_amount - Amount::from(700));
  112. Ok(())
  113. }
  114. /// Tests concurrent melt operations are isolated
  115. #[tokio::test]
  116. async fn test_concurrent_melts_isolated() -> Result<()> {
  117. setup_tracing();
  118. let mint = create_and_start_test_mint().await?;
  119. let wallet = create_test_wallet_for_mint(mint.clone()).await?;
  120. // Fund wallet with enough for multiple melts
  121. fund_wallet(wallet.clone(), 2000, None).await?;
  122. // Create two invoices
  123. let invoice1 = create_fake_invoice(200_000, "melt 1".to_string());
  124. let invoice2 = create_fake_invoice(300_000, "melt 2".to_string());
  125. // Get quotes
  126. let quote1 = wallet
  127. .melt_quote(PaymentMethod::BOLT11, invoice1.to_string(), None, None)
  128. .await?;
  129. let quote2 = wallet
  130. .melt_quote(PaymentMethod::BOLT11, invoice2.to_string(), None, None)
  131. .await?;
  132. // Execute both melts concurrently
  133. let wallet1 = wallet.clone();
  134. let wallet2 = wallet.clone();
  135. let quote_id1 = quote1.id.clone();
  136. let quote_id2 = quote2.id.clone();
  137. // Prepare both melts
  138. let prepared1 = wallet1
  139. .prepare_melt(&quote_id1, std::collections::HashMap::new())
  140. .await?;
  141. let prepared2 = wallet2
  142. .prepare_melt(&quote_id2, std::collections::HashMap::new())
  143. .await?;
  144. // Confirm both in parallel
  145. let (result1, result2) = tokio::join!(prepared1.confirm(), prepared2.confirm());
  146. // Both should succeed
  147. let confirmed1 = result1?;
  148. let confirmed2 = result2?;
  149. assert_eq!(confirmed1.state(), MeltQuoteState::Paid);
  150. assert_eq!(confirmed2.state(), MeltQuoteState::Paid);
  151. // Verify total amount melted
  152. let final_balance = wallet.total_balance().await?;
  153. assert!(final_balance < Amount::from(1500)); // At least 500 melted
  154. Ok(())
  155. }
  156. // =============================================================================
  157. // Melt Saga Input Fee Tests
  158. // =============================================================================
  159. /// Tests that melt saga correctly includes input fees when calculating total needed.
  160. ///
  161. /// This is a regression test for a bug where confirm_melt calculated:
  162. /// inputs_needed_amount = quote.amount + fee_reserve
  163. /// but should calculate:
  164. /// inputs_needed_amount = quote.amount + fee_reserve + input_fee
  165. ///
  166. /// The bug manifested as: "not enough inputs provided for melt. Provided: X, needed: X+1"
  167. ///
  168. /// Scenario:
  169. /// - Mint with 1000 ppk (1 sat per proof input fee)
  170. /// - Melt for 26 sats
  171. /// - fee_reserve = 2 sats
  172. /// - If wallet has proofs that don't exactly match, it swaps first
  173. /// - The swap produces proofs totaling (amount + fee_reserve) = 28 sats
  174. /// - But mint actually needs (amount + fee_reserve + input_fee) = 29 sats
  175. ///
  176. /// Before fix: Melt fails with "not enough inputs provided for melt"
  177. /// After fix: Melt succeeds
  178. #[tokio::test]
  179. async fn test_melt_saga_includes_input_fees() -> Result<()> {
  180. use cdk::nuts::CurrencyUnit;
  181. setup_tracing();
  182. let mint = create_and_start_test_mint().await?;
  183. let wallet = create_test_wallet_for_mint(mint.clone()).await?;
  184. // Rotate to keyset with 1000 ppk = 1 sat per proof fee
  185. // This is required to trigger the bug - without input fees, the calculation is correct
  186. mint.rotate_keyset(
  187. CurrencyUnit::Sat,
  188. cdk_integration_tests::standard_keyset_amounts(32),
  189. 1000, // 1 sat per proof input fee
  190. )
  191. .await
  192. .expect("Failed to rotate keyset");
  193. // Brief pause to ensure keyset rotation is complete
  194. tokio::time::sleep(std::time::Duration::from_millis(100)).await;
  195. // Fund wallet with enough to cover melt amount + fee_reserve + input fees
  196. // Use larger amounts to ensure there are enough proofs of the right denominations
  197. let initial_amount = 500u64;
  198. fund_wallet(wallet.clone(), initial_amount, None).await?;
  199. let initial_balance = wallet.total_balance().await?;
  200. assert_eq!(initial_balance, Amount::from(initial_amount));
  201. // Create melt quote for an amount that requires a swap
  202. // 100 sats = 100000 msats
  203. // fee_reserve should be ~2 sats (2% of 100)
  204. // inputs_needed without input_fee = 102 sats
  205. // With input_fee (depends on proof count), mint needs more
  206. let invoice = create_fake_invoice(100_000, "test melt with fees".to_string());
  207. let melt_quote = wallet
  208. .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
  209. .await?;
  210. tracing::info!(
  211. "Melt quote: amount={}, fee_reserve={}",
  212. melt_quote.amount,
  213. melt_quote.fee_reserve
  214. );
  215. // Perform the melt - this should succeed even with input fees
  216. // Before the fix, this would fail with:
  217. // "not enough inputs provided for melt. Provided: X, needed: X+1"
  218. let prepared = wallet
  219. .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
  220. .await?;
  221. let confirmed = prepared.confirm().await?;
  222. assert_eq!(confirmed.state(), MeltQuoteState::Paid);
  223. tracing::info!(
  224. "Melt succeeded: amount={}, fee_paid={}",
  225. confirmed.amount(),
  226. confirmed.fee_paid()
  227. );
  228. // Verify final balance makes sense
  229. let final_balance = wallet.total_balance().await?;
  230. assert!(
  231. final_balance < initial_balance,
  232. "Balance should decrease after melt"
  233. );
  234. Ok(())
  235. }
  236. /// Regression test: Melt with swap should account for actual output proof count.
  237. ///
  238. /// This test reproduces a bug where:
  239. /// 1. Wallet has many small proofs (non-optimal denominations)
  240. /// 2. User tries to melt an amount that requires a swap
  241. /// 3. The swap produces more proofs than the "optimal" estimate
  242. /// 4. The actual input_fee is higher than estimated
  243. /// 5. Result: "Insufficient funds" even though wallet has enough balance
  244. ///
  245. /// The issue was that `estimated_melt_fee` was based on `inputs_needed_amount.split()`
  246. /// but after swap with `amount=None`, the actual proof count could be higher,
  247. /// leading to a higher `actual_input_fee`.
  248. ///
  249. /// Example from real failure:
  250. /// - inputs_needed_amount = 6700 (optimal split = 7 proofs, fee = 1)
  251. /// - selection_amount = 6701
  252. /// - Selected 12 proofs totaling 6703, swap_fee = 2
  253. /// - After swap: 6701 worth but 13 proofs (not optimal 7!)
  254. /// - actual_input_fee = 2 (not 1!)
  255. /// - Need: 6633 + 67 + 2 = 6702, Have: 6701 → Insufficient funds!
  256. #[tokio::test]
  257. async fn test_melt_with_swap_non_optimal_proofs() -> Result<()> {
  258. use cdk::amount::SplitTarget;
  259. use cdk::nuts::CurrencyUnit;
  260. setup_tracing();
  261. let mint = create_and_start_test_mint().await?;
  262. let wallet = create_test_wallet_for_mint(mint.clone()).await?;
  263. // Use a keyset with 100 ppk (0.1 sat per proof, so ~10 proofs = 1 sat fee)
  264. // This makes the fee difference noticeable when proof count differs
  265. mint.rotate_keyset(
  266. CurrencyUnit::Sat,
  267. cdk_integration_tests::standard_keyset_amounts(32),
  268. 100, // 0.1 sat per proof input fee
  269. )
  270. .await
  271. .expect("Failed to rotate keyset");
  272. tokio::time::sleep(std::time::Duration::from_millis(100)).await;
  273. // Fund wallet with many 1-sat proofs (very non-optimal)
  274. // This forces a swap when trying to melt, and the swap output
  275. // may have more proofs than the "optimal" estimate
  276. let initial_amount = 200u64;
  277. fund_wallet(
  278. wallet.clone(),
  279. initial_amount,
  280. Some(SplitTarget::Value(Amount::ONE)),
  281. )
  282. .await?;
  283. let initial_balance = wallet.total_balance().await?;
  284. assert_eq!(initial_balance, Amount::from(initial_amount));
  285. // Verify we have many small proofs
  286. let proofs = wallet.get_unspent_proofs().await?;
  287. tracing::info!("Funded with {} proofs", proofs.len());
  288. assert!(
  289. proofs.len() > 50,
  290. "Should have many small proofs to force non-optimal swap"
  291. );
  292. // Create melt quote - amount chosen to require a swap
  293. // With 200 sats in 1-sat proofs, melting 100 sats should require swapping
  294. let invoice = create_fake_invoice(100_000, "test melt with non-optimal proofs".to_string());
  295. let melt_quote = wallet
  296. .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
  297. .await?;
  298. tracing::info!(
  299. "Melt quote: amount={}, fee_reserve={}",
  300. melt_quote.amount,
  301. melt_quote.fee_reserve
  302. );
  303. // This melt should succeed even with non-optimal proofs
  304. // Before fix: fails with "Insufficient funds" because actual_input_fee > estimated
  305. let prepared = wallet
  306. .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
  307. .await?;
  308. let confirmed = prepared.confirm().await?;
  309. assert_eq!(confirmed.state(), MeltQuoteState::Paid);
  310. tracing::info!(
  311. "Melt succeeded: amount={}, fee_paid={}",
  312. confirmed.amount(),
  313. confirmed.fee_paid()
  314. );
  315. // Verify balance decreased appropriately
  316. let final_balance = wallet.total_balance().await?;
  317. assert!(
  318. final_balance < initial_balance,
  319. "Balance should decrease after melt"
  320. );
  321. Ok(())
  322. }
  323. /// Tests recovery when a crash occurs after the swap but before the melt request is persisted.
  324. ///
  325. /// This simulates the "Swap Gap":
  326. /// 1. MeltSaga prepares (ProofsReserved).
  327. /// 2. Swap executes (Old proofs spent, New proofs created).
  328. /// 3. CRASH (MeltSaga not updated to MeltRequested).
  329. /// 4. Recovery runs.
  330. ///
  331. /// Expected behavior:
  332. /// - The recovery should see ProofsReserved.
  333. /// - It attempts to revert reservation.
  334. /// - Since old proofs are spent (deleted from DB), revert does nothing.
  335. /// - Saga is deleted.
  336. /// - Wallet contains NEW proofs from the swap.
  337. /// - No double counting (Old + New).
  338. #[tokio::test]
  339. async fn test_melt_swap_gap_recovery() -> Result<()> {
  340. use cdk::amount::SplitTarget;
  341. use cdk::nuts::CurrencyUnit;
  342. setup_tracing();
  343. let mint = create_and_start_test_mint().await?;
  344. let wallet = create_test_wallet_for_mint(mint.clone()).await?;
  345. // 1. Configure Mint with Input Fees to force a swap
  346. // 1000 ppk = 1 sat per proof
  347. mint.rotate_keyset(
  348. CurrencyUnit::Sat,
  349. cdk_integration_tests::standard_keyset_amounts(32),
  350. 1000,
  351. )
  352. .await
  353. .expect("Failed to rotate keyset");
  354. tokio::time::sleep(std::time::Duration::from_millis(100)).await;
  355. // 2. Fund Wallet with small proofs
  356. // 500 sats total in 50-sat proofs.
  357. let initial_amount = 500u64;
  358. fund_wallet(
  359. wallet.clone(),
  360. initial_amount,
  361. Some(SplitTarget::Value(Amount::from(50))),
  362. )
  363. .await?;
  364. let initial_balance = wallet.total_balance().await?;
  365. assert_eq!(initial_balance, Amount::from(initial_amount));
  366. // 3. Create Melt Quote
  367. let invoice = create_fake_invoice(100_000, "test gap".to_string());
  368. let melt_quote = wallet
  369. .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
  370. .await?;
  371. // 4. Prepare Melt
  372. let prepared = wallet
  373. .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
  374. .await?;
  375. // Verify we have proofs to swap
  376. let proofs_to_swap = prepared.proofs_to_swap();
  377. assert!(!proofs_to_swap.is_empty(), "Should have proofs to swap");
  378. // 5. Simulate the Gap (Manual Swap)
  379. // Calculate target amount (what MeltSaga would do)
  380. // We only need to swap for the amount + reserve, change will handle the rest.
  381. // Including input_fee in target request causes us to request more than we have available
  382. // (since input_fee is deducted from inputs).
  383. let target_swap_amount = melt_quote.amount + melt_quote.fee_reserve;
  384. tracing::info!("Simulating swap for amount: {}", target_swap_amount);
  385. // Perform the swap
  386. // Note: this consumes the old proofs from the DB and adds new ones.
  387. // The `prepared` saga state in memory still points to old proofs,
  388. // and the DB saga state is still 'ProofsReserved' with old proofs.
  389. let swapped_proofs = wallet
  390. .swap(
  391. Some(target_swap_amount),
  392. SplitTarget::None,
  393. proofs_to_swap.clone(),
  394. None,
  395. false,
  396. )
  397. .await?;
  398. assert!(swapped_proofs.is_some(), "Swap should succeed");
  399. let swapped_proofs = swapped_proofs.unwrap();
  400. // The swap places the requested amount in 'Reserved' state.
  401. // Since we are simulating a crash where these were not consumed,
  402. // we need to set them to Unspent to verify the wallet balance is conserved.
  403. // In a real scenario, a "stuck reserved proofs" cleanup mechanism would handle this.
  404. let ys = swapped_proofs.ys()?;
  405. wallet
  406. .localstore
  407. .update_proofs_state(ys, cdk::nuts::State::Unspent)
  408. .await?;
  409. // 6. Recover
  410. // At this point, the MeltSaga in DB is stale (points to spent proofs).
  411. // Recovery should clean it up.
  412. let report = wallet.recover_incomplete_sagas().await?;
  413. tracing::info!("Recovery report: {:?}", report);
  414. // 7. Verify
  415. // The saga should be gone/handled.
  416. // We check the DB directly to ensure saga is gone.
  417. let saga = wallet.localstore.get_saga(&prepared.operation_id()).await?;
  418. assert!(saga.is_none(), "Saga should be deleted after recovery");
  419. // Check Balance
  420. // We expect: Initial - Swap Fees.
  421. // The melt didn't happen (cancelled).
  422. // The swap happened.
  423. let current_balance = wallet.total_balance().await?;
  424. assert!(
  425. current_balance < Amount::from(initial_amount),
  426. "Balance should have decreased by fee"
  427. );
  428. assert!(
  429. current_balance > Amount::from(initial_amount) - Amount::from(50),
  430. "Fee shouldn't be huge. Initial: {}, Current: {}",
  431. initial_amount,
  432. current_balance
  433. );
  434. Ok(())
  435. }