wallet_saga.rs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  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. true,
  191. )
  192. .await
  193. .expect("Failed to rotate keyset");
  194. // Brief pause to ensure keyset rotation is complete
  195. tokio::time::sleep(std::time::Duration::from_millis(100)).await;
  196. // Fund wallet with enough to cover melt amount + fee_reserve + input fees
  197. // Use larger amounts to ensure there are enough proofs of the right denominations
  198. let initial_amount = 500u64;
  199. fund_wallet(wallet.clone(), initial_amount, None).await?;
  200. let initial_balance = wallet.total_balance().await?;
  201. assert_eq!(initial_balance, Amount::from(initial_amount));
  202. // Create melt quote for an amount that requires a swap
  203. // 100 sats = 100000 msats
  204. // fee_reserve should be ~2 sats (2% of 100)
  205. // inputs_needed without input_fee = 102 sats
  206. // With input_fee (depends on proof count), mint needs more
  207. let invoice = create_fake_invoice(100_000, "test melt with fees".to_string());
  208. let melt_quote = wallet
  209. .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
  210. .await?;
  211. tracing::info!(
  212. "Melt quote: amount={}, fee_reserve={}",
  213. melt_quote.amount,
  214. melt_quote.fee_reserve
  215. );
  216. // Perform the melt - this should succeed even with input fees
  217. // Before the fix, this would fail with:
  218. // "not enough inputs provided for melt. Provided: X, needed: X+1"
  219. let prepared = wallet
  220. .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
  221. .await?;
  222. let confirmed = prepared.confirm().await?;
  223. assert_eq!(confirmed.state(), MeltQuoteState::Paid);
  224. tracing::info!(
  225. "Melt succeeded: amount={}, fee_paid={}",
  226. confirmed.amount(),
  227. confirmed.fee_paid()
  228. );
  229. // Verify final balance makes sense
  230. let final_balance = wallet.total_balance().await?;
  231. assert!(
  232. final_balance < initial_balance,
  233. "Balance should decrease after melt"
  234. );
  235. Ok(())
  236. }
  237. /// Regression test: Melt with swap should account for actual output proof count.
  238. ///
  239. /// This test reproduces a bug where:
  240. /// 1. Wallet has many small proofs (non-optimal denominations)
  241. /// 2. User tries to melt an amount that requires a swap
  242. /// 3. The swap produces more proofs than the "optimal" estimate
  243. /// 4. The actual input_fee is higher than estimated
  244. /// 5. Result: "Insufficient funds" even though wallet has enough balance
  245. ///
  246. /// The issue was that `estimated_melt_fee` was based on `inputs_needed_amount.split()`
  247. /// but after swap with `amount=None`, the actual proof count could be higher,
  248. /// leading to a higher `actual_input_fee`.
  249. ///
  250. /// Example from real failure:
  251. /// - inputs_needed_amount = 6700 (optimal split = 7 proofs, fee = 1)
  252. /// - selection_amount = 6701
  253. /// - Selected 12 proofs totaling 6703, swap_fee = 2
  254. /// - After swap: 6701 worth but 13 proofs (not optimal 7!)
  255. /// - actual_input_fee = 2 (not 1!)
  256. /// - Need: 6633 + 67 + 2 = 6702, Have: 6701 → Insufficient funds!
  257. #[tokio::test]
  258. async fn test_melt_with_swap_non_optimal_proofs() -> Result<()> {
  259. use cdk::amount::SplitTarget;
  260. use cdk::nuts::CurrencyUnit;
  261. setup_tracing();
  262. let mint = create_and_start_test_mint().await?;
  263. let wallet = create_test_wallet_for_mint(mint.clone()).await?;
  264. // Use a keyset with 100 ppk (0.1 sat per proof, so ~10 proofs = 1 sat fee)
  265. // This makes the fee difference noticeable when proof count differs
  266. mint.rotate_keyset(
  267. CurrencyUnit::Sat,
  268. cdk_integration_tests::standard_keyset_amounts(32),
  269. 100, // 0.1 sat per proof input fee
  270. true,
  271. )
  272. .await
  273. .expect("Failed to rotate keyset");
  274. tokio::time::sleep(std::time::Duration::from_millis(100)).await;
  275. // Fund wallet with many 1-sat proofs (very non-optimal)
  276. // This forces a swap when trying to melt, and the swap output
  277. // may have more proofs than the "optimal" estimate
  278. let initial_amount = 200u64;
  279. fund_wallet(
  280. wallet.clone(),
  281. initial_amount,
  282. Some(SplitTarget::Value(Amount::ONE)),
  283. )
  284. .await?;
  285. let initial_balance = wallet.total_balance().await?;
  286. assert_eq!(initial_balance, Amount::from(initial_amount));
  287. // Verify we have many small proofs
  288. let proofs = wallet.get_unspent_proofs().await?;
  289. tracing::info!("Funded with {} proofs", proofs.len());
  290. assert!(
  291. proofs.len() > 50,
  292. "Should have many small proofs to force non-optimal swap"
  293. );
  294. // Create melt quote - amount chosen to require a swap
  295. // With 200 sats in 1-sat proofs, melting 100 sats should require swapping
  296. let invoice = create_fake_invoice(100_000, "test melt with non-optimal proofs".to_string());
  297. let melt_quote = wallet
  298. .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
  299. .await?;
  300. tracing::info!(
  301. "Melt quote: amount={}, fee_reserve={}",
  302. melt_quote.amount,
  303. melt_quote.fee_reserve
  304. );
  305. // This melt should succeed even with non-optimal proofs
  306. // Before fix: fails with "Insufficient funds" because actual_input_fee > estimated
  307. let prepared = wallet
  308. .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
  309. .await?;
  310. let confirmed = prepared.confirm().await?;
  311. assert_eq!(confirmed.state(), MeltQuoteState::Paid);
  312. tracing::info!(
  313. "Melt succeeded: amount={}, fee_paid={}",
  314. confirmed.amount(),
  315. confirmed.fee_paid()
  316. );
  317. // Verify balance decreased appropriately
  318. let final_balance = wallet.total_balance().await?;
  319. assert!(
  320. final_balance < initial_balance,
  321. "Balance should decrease after melt"
  322. );
  323. Ok(())
  324. }
  325. /// Tests recovery when a crash occurs after the swap but before the melt request is persisted.
  326. ///
  327. /// This simulates the "Swap Gap":
  328. /// 1. MeltSaga prepares (ProofsReserved).
  329. /// 2. Swap executes (Old proofs spent, New proofs created).
  330. /// 3. CRASH (MeltSaga not updated to MeltRequested).
  331. /// 4. Recovery runs.
  332. ///
  333. /// Expected behavior:
  334. /// - The recovery should see ProofsReserved.
  335. /// - It attempts to revert reservation.
  336. /// - Since old proofs are spent (deleted from DB), revert does nothing.
  337. /// - Saga is deleted.
  338. /// - Wallet contains NEW proofs from the swap.
  339. /// - No double counting (Old + New).
  340. #[tokio::test]
  341. async fn test_melt_swap_gap_recovery() -> Result<()> {
  342. use cdk::amount::SplitTarget;
  343. use cdk::nuts::CurrencyUnit;
  344. setup_tracing();
  345. let mint = create_and_start_test_mint().await?;
  346. let wallet = create_test_wallet_for_mint(mint.clone()).await?;
  347. // 1. Configure Mint with Input Fees to force a swap
  348. // 1000 ppk = 1 sat per proof
  349. mint.rotate_keyset(
  350. CurrencyUnit::Sat,
  351. cdk_integration_tests::standard_keyset_amounts(32),
  352. 1000,
  353. true,
  354. )
  355. .await
  356. .expect("Failed to rotate keyset");
  357. tokio::time::sleep(std::time::Duration::from_millis(100)).await;
  358. // 2. Fund Wallet with small proofs
  359. // 500 sats total in 50-sat proofs.
  360. let initial_amount = 500u64;
  361. fund_wallet(
  362. wallet.clone(),
  363. initial_amount,
  364. Some(SplitTarget::Value(Amount::from(50))),
  365. )
  366. .await?;
  367. let initial_balance = wallet.total_balance().await?;
  368. assert_eq!(initial_balance, Amount::from(initial_amount));
  369. // 3. Create Melt Quote
  370. let invoice = create_fake_invoice(100_000, "test gap".to_string());
  371. let melt_quote = wallet
  372. .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
  373. .await?;
  374. // 4. Prepare Melt
  375. let prepared = wallet
  376. .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
  377. .await?;
  378. // Verify we have proofs to swap
  379. let proofs_to_swap = prepared.proofs_to_swap();
  380. assert!(!proofs_to_swap.is_empty(), "Should have proofs to swap");
  381. // 5. Simulate the Gap (Manual Swap)
  382. // Calculate target amount (what MeltSaga would do)
  383. // We only need to swap for the amount + reserve, change will handle the rest.
  384. // Including input_fee in target request causes us to request more than we have available
  385. // (since input_fee is deducted from inputs).
  386. let target_swap_amount = melt_quote.amount + melt_quote.fee_reserve;
  387. tracing::info!("Simulating swap for amount: {}", target_swap_amount);
  388. // Perform the swap
  389. // Note: this consumes the old proofs from the DB and adds new ones.
  390. // The `prepared` saga state in memory still points to old proofs,
  391. // and the DB saga state is still 'ProofsReserved' with old proofs.
  392. let swapped_proofs = wallet
  393. .swap(
  394. Some(target_swap_amount),
  395. SplitTarget::None,
  396. proofs_to_swap.clone(),
  397. None,
  398. false,
  399. )
  400. .await?;
  401. assert!(swapped_proofs.is_some(), "Swap should succeed");
  402. let swapped_proofs = swapped_proofs.unwrap();
  403. // The swap places the requested amount in 'Reserved' state.
  404. // Since we are simulating a crash where these were not consumed,
  405. // we need to set them to Unspent to verify the wallet balance is conserved.
  406. // In a real scenario, a "stuck reserved proofs" cleanup mechanism would handle this.
  407. let ys = swapped_proofs.ys()?;
  408. wallet
  409. .localstore
  410. .update_proofs_state(ys, cdk::nuts::State::Unspent)
  411. .await?;
  412. // 6. Recover
  413. // At this point, the MeltSaga in DB is stale (points to spent proofs).
  414. // Recovery should clean it up.
  415. let report = wallet.recover_incomplete_sagas().await?;
  416. tracing::info!("Recovery report: {:?}", report);
  417. // 7. Verify
  418. // The saga should be gone/handled.
  419. // We check the DB directly to ensure saga is gone.
  420. let saga = wallet.localstore.get_saga(&prepared.operation_id()).await?;
  421. assert!(saga.is_none(), "Saga should be deleted after recovery");
  422. // Check Balance
  423. // We expect: Initial - Swap Fees.
  424. // The melt didn't happen (cancelled).
  425. // The swap happened.
  426. let current_balance = wallet.total_balance().await?;
  427. assert!(
  428. current_balance < Amount::from(initial_amount),
  429. "Balance should have decreased by fee"
  430. );
  431. assert!(
  432. current_balance > Amount::from(initial_amount) - Amount::from(50),
  433. "Fee shouldn't be huge. Initial: {}, Current: {}",
  434. initial_amount,
  435. current_balance
  436. );
  437. Ok(())
  438. }