diff --git a/crates/cdk-integration-tests/src/lib.rs b/crates/cdk-integration-tests/src/lib.rs index cd5f4538..4331ac22 100644 --- a/crates/cdk-integration-tests/src/lib.rs +++ b/crates/cdk-integration-tests/src/lib.rs @@ -23,7 +23,6 @@ use std::sync::Arc; use anyhow::{anyhow, bail, Result}; use cashu::Bolt11Invoice; use cdk::amount::{Amount, SplitTarget}; -use cdk::nuts::State; use cdk::{StreamExt, Wallet}; use cdk_fake_wallet::create_fake_invoice; use init_regtest::{get_lnd_dir, LND_RPC_ADDR}; @@ -65,43 +64,6 @@ pub fn get_second_mint_url_from_env() -> String { } } -// Get all pending from wallet and attempt to swap -// Will panic if there are no pending -// Will return Ok if swap fails as expected -pub async fn attempt_to_swap_pending(wallet: &Wallet) -> Result<()> { - let pending = wallet - .localstore - .get_proofs(None, None, Some(vec![State::Pending]), None) - .await?; - - assert!(!pending.is_empty()); - - let swap = wallet - .swap( - None, - SplitTarget::None, - pending.into_iter().map(|p| p.proof).collect(), - None, - false, - ) - .await; - - match swap { - Ok(_swap) => { - bail!("These proofs should be pending") - } - Err(err) => match err { - cdk::error::Error::TokenPending => (), - _ => { - println!("{err:?}"); - bail!("Wrong error") - } - }, - } - - Ok(()) -} - // This is the ln wallet we use to send/receive ln payements as the wallet pub async fn init_lnd_client(work_dir: &Path) -> LndClient { let lnd_dir = get_lnd_dir(work_dir, "one"); diff --git a/crates/cdk-integration-tests/tests/fake_wallet.rs b/crates/cdk-integration-tests/tests/fake_wallet.rs index b6b271fe..942f8ce6 100644 --- a/crates/cdk-integration-tests/tests/fake_wallet.rs +++ b/crates/cdk-integration-tests/tests/fake_wallet.rs @@ -28,7 +28,6 @@ use cdk::wallet::types::TransactionDirection; use cdk::wallet::{HttpClient, MintConnector, Wallet}; use cdk::StreamExt; use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription}; -use cdk_integration_tests::attempt_to_swap_pending; use cdk_sqlite::wallet::memory; const MINT_URL: &str = "http://127.0.0.1:8086"; @@ -55,6 +54,8 @@ async fn test_fake_tokens_pending() { .expect("payment") .expect("no error"); + let old_balance = wallet.total_balance().await.expect("balance"); + let fake_description = FakeInvoiceDescription { pay_invoice_state: MeltQuoteState::Pending, check_payment_state: MeltQuoteState::Pending, @@ -70,7 +71,18 @@ async fn test_fake_tokens_pending() { assert!(melt.is_err()); - attempt_to_swap_pending(&wallet).await.unwrap(); + // melt failed, but there is new code to reclaim unspent proofs + assert_eq!( + old_balance, + wallet.total_balance().await.expect("new balance") + ); + + assert!(wallet + .localstore + .get_proofs(None, None, Some(vec![State::Pending]), None) + .await + .unwrap() + .is_empty()); } /// Tests that if the pay error fails and the check returns unknown or failed, @@ -126,15 +138,8 @@ async fn test_fake_melt_payment_fail() { let melt = wallet.melt(&melt_quote.id).await; assert!(melt.is_err()); - // The mint should have unset proofs from pending since payment failed - let all_proof = wallet.get_unspent_proofs().await.unwrap(); - let states = wallet.check_proofs_spent(all_proof).await.unwrap(); - for state in states { - assert!(state.state == State::Unspent); - } - let wallet_bal = wallet.total_balance().await.unwrap(); - assert_eq!(wallet_bal, 98.into()); + assert_eq!(wallet_bal, 100.into()); } /// Tests that when both the pay_invoice and check_invoice both fail, @@ -160,6 +165,8 @@ async fn test_fake_melt_payment_fail_and_check() { .expect("payment") .expect("no error"); + let old_balance = wallet.total_balance().await.expect("balance"); + let fake_description = FakeInvoiceDescription { pay_invoice_state: MeltQuoteState::Unknown, check_payment_state: MeltQuoteState::Unknown, @@ -175,13 +182,18 @@ async fn test_fake_melt_payment_fail_and_check() { let melt = wallet.melt(&melt_quote.id).await; assert!(melt.is_err()); - let pending = wallet + // melt failed, but there is new code to reclaim unspent proofs + assert_eq!( + old_balance, + wallet.total_balance().await.expect("new balance") + ); + + assert!(wallet .localstore .get_proofs(None, None, Some(vec![State::Pending]), None) .await - .unwrap(); - - assert!(!pending.is_empty()); + .unwrap() + .is_empty()); } /// Tests that when the ln backend returns a failed status but does not error, @@ -214,6 +226,8 @@ async fn test_fake_melt_payment_return_fail_status() { check_err: false, }; + let old_balance = wallet.total_balance().await.expect("balance"); + let invoice = create_fake_invoice(7000, serde_json::to_string(&fake_description).unwrap()); let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap(); @@ -247,15 +261,19 @@ async fn test_fake_melt_payment_return_fail_status() { let melt = wallet.melt(&melt_quote.id).await; assert!(melt.is_err()); + assert_eq!( + old_balance, + wallet.total_balance().await.expect("new balance") + ); + wallet.check_all_pending_proofs().await.unwrap(); - let pending = wallet + assert!(wallet .localstore .get_proofs(None, None, Some(vec![State::Pending]), None) .await - .unwrap(); - - assert!(!pending.is_empty()); + .unwrap() + .is_empty()); } /// Tests that when the ln backend returns an error with unknown status, @@ -281,6 +299,8 @@ async fn test_fake_melt_payment_error_unknown() { .expect("payment") .expect("no error"); + let old_balance = wallet.total_balance().await.expect("balance"); + let fake_description = FakeInvoiceDescription { pay_invoice_state: MeltQuoteState::Failed, check_payment_state: MeltQuoteState::Unknown, @@ -313,13 +333,17 @@ async fn test_fake_melt_payment_error_unknown() { wallet.check_all_pending_proofs().await.unwrap(); - let pending = wallet + assert_eq!( + old_balance, + wallet.total_balance().await.expect("new balance") + ); + + assert!(wallet .localstore .get_proofs(None, None, Some(vec![State::Pending]), None) .await - .unwrap(); - - assert!(!pending.is_empty()); + .unwrap() + .is_empty()); } /// Tests that when the ln backend returns an error but the second check returns paid, @@ -345,6 +369,8 @@ async fn test_fake_melt_payment_err_paid() { .expect("payment") .expect("no error"); + let old_balance = wallet.total_balance().await.expect("balance"); + let fake_description = FakeInvoiceDescription { pay_invoice_state: MeltQuoteState::Failed, check_payment_state: MeltQuoteState::Paid, @@ -361,6 +387,19 @@ async fn test_fake_melt_payment_err_paid() { assert!(melt.fee_paid == Amount::ZERO); assert!(melt.amount == Amount::from(7)); + + // melt failed, but there is new code to reclaim unspent proofs + assert_eq!( + old_balance - melt.amount, + wallet.total_balance().await.expect("new balance") + ); + + assert!(wallet + .localstore + .get_proofs(None, None, Some(vec![State::Pending]), None) + .await + .unwrap() + .is_empty()); } /// Tests that change outputs in a melt quote are correctly handled @@ -1387,3 +1426,155 @@ async fn test_fake_mint_duplicate_proofs_melt() { } } } + +/// Tests that wallet automatically recovers proofs after a failed melt operation +/// by swapping them to new proofs, preventing loss of funds +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_wallet_proof_recovery_after_failed_melt() { + let wallet = Wallet::new( + MINT_URL, + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect("failed to create new wallet"); + + // 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(); + + assert_eq!(wallet.total_balance().await.unwrap(), Amount::from(100)); + + // Create a melt quote that will fail + let fake_description = FakeInvoiceDescription { + pay_invoice_state: MeltQuoteState::Unknown, + check_payment_state: MeltQuoteState::Unpaid, + pay_err: true, + check_err: false, + }; + + let invoice = create_fake_invoice(1000, serde_json::to_string(&fake_description).unwrap()); + let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap(); + + // Attempt to melt - this should fail but trigger proof recovery + let melt_result = wallet.melt(&melt_quote.id).await; + assert!(melt_result.is_err(), "Melt should have failed"); + + // Verify wallet still has balance (proofs recovered) + assert_eq!( + wallet.total_balance().await.unwrap(), + Amount::from(100), + "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 + .melt_quote(valid_invoice.to_string(), None) + .await + .unwrap(); + + let successful_melt = wallet.melt(&valid_melt_quote.id).await; + assert!( + successful_melt.is_ok(), + "Should be able to spend recovered proofs" + ); +} + +/// Tests that wallet automatically recovers proofs after a failed swap operation +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_wallet_proof_recovery_after_failed_swap() { + let wallet = Wallet::new( + MINT_URL, + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect("failed to create new wallet"); + + // 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(); + + assert_eq!(wallet.total_balance().await.unwrap(), Amount::from(100)); + + let unspent_proofs = wallet.get_unspent_proofs().await.unwrap(); + + // Create an invalid swap by manually constructing a request that will fail + // We'll use the wallet's swap with invalid parameters to trigger a failure + let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + + // Create invalid swap request (requesting more than we have) + let preswap = PreMintSecrets::random( + active_keyset_id, + 1000.into(), // More than the 100 we have + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); + + let swap_request = SwapRequest::new(unspent_proofs.clone(), preswap.blinded_messages()); + + // Use HTTP client directly to bypass wallet's validation and trigger recovery + let http_client = HttpClient::new(MINT_URL.parse().unwrap(), None); + let response = http_client.post_swap(swap_request).await; + assert!(response.is_err(), "Swap should have failed"); + + // Note: The HTTP client doesn't trigger the wallet's try_proof_operation wrapper + // So we need to test through the wallet's own methods + // After the failed HTTP request, the proofs are still in the wallet's database + + // Verify balance is still available after the failed operation + assert_eq!( + wallet.total_balance().await.unwrap(), + Amount::from(100), + "Balance should still be available" + ); + + // Verify we can perform a successful swap operation + let successful_swap = wallet + .swap(None, SplitTarget::None, unspent_proofs, None, false) + .await; + + assert!( + successful_swap.is_ok(), + "Should be able to swap after failed operation" + ); + + // Verify the proofs were swapped to new ones + let final_proofs = wallet.get_unspent_proofs().await.unwrap(); + let final_ys: Vec<_> = final_proofs.iter().map(|p| p.y().unwrap()).collect(); + + // The Ys should be different after the successful swap + assert!( + initial_ys.iter().any(|y| !final_ys.contains(y)), + "Proofs should have been swapped to new ones" + ); +} diff --git a/crates/cdk/src/wallet/builder.rs b/crates/cdk/src/wallet/builder.rs index 500b77a4..bcfc403e 100644 --- a/crates/cdk/src/wallet/builder.rs +++ b/crates/cdk/src/wallet/builder.rs @@ -172,6 +172,7 @@ impl WalletBuilder { seed, client: client.clone(), subscription: SubscriptionManager::new(client, self.use_http_subscription), + in_error_swap_reverted_proofs: Arc::new(false.into()), }) } } diff --git a/crates/cdk/src/wallet/melt/melt_bolt11.rs b/crates/cdk/src/wallet/melt/melt_bolt11.rs index 58b23353..78b2a773 100644 --- a/crates/cdk/src/wallet/melt/melt_bolt11.rs +++ b/crates/cdk/src/wallet/melt/melt_bolt11.rs @@ -198,25 +198,25 @@ impl Wallet { ); let melt_response = match quote_info.payment_method { - cdk_common::PaymentMethod::Bolt11 => self.client.post_melt(request).await, - cdk_common::PaymentMethod::Bolt12 => self.client.post_melt_bolt12(request).await, + cdk_common::PaymentMethod::Bolt11 => { + self.try_proof_operation_or_reclaim( + request.inputs().clone(), + self.client.post_melt(request), + ) + .await? + } + cdk_common::PaymentMethod::Bolt12 => { + self.try_proof_operation_or_reclaim( + request.inputs().clone(), + self.client.post_melt_bolt12(request), + ) + .await? + } cdk_common::PaymentMethod::Custom(_) => { return Err(Error::UnsupportedPaymentMethod); } }; - let melt_response = match melt_response { - Ok(melt_response) => melt_response, - Err(err) => { - tracing::error!("Could not melt: {}", err); - tracing::info!("Checking status of input proofs."); - - self.reclaim_unspent(proofs).await?; - - return Err(err); - } - }; - let active_keys = self .localstore .get_keys(&active_keyset_id) diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index 999ff875..4a9ed797 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::str::FromStr; +use std::sync::atomic::AtomicBool; use std::sync::Arc; use cdk_common::amount::FeeAndAmounts; @@ -45,6 +46,7 @@ pub mod multi_mint_wallet; pub mod payment_request; mod proofs; mod receive; +mod reclaim; mod send; #[cfg(not(target_arch = "wasm32"))] mod streams; @@ -91,6 +93,7 @@ pub struct Wallet { seed: [u8; 64], client: Arc, subscription: SubscriptionManager, + in_error_swap_reverted_proofs: Arc, } const ALPHANUMERIC: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; diff --git a/crates/cdk/src/wallet/reclaim.rs b/crates/cdk/src/wallet/reclaim.rs new file mode 100644 index 00000000..648e022e --- /dev/null +++ b/crates/cdk/src/wallet/reclaim.rs @@ -0,0 +1,105 @@ +use std::future::Future; + +use crate::nuts::{Proofs, State}; +use crate::{Error, Wallet}; + +#[cfg(not(target_arch = "wasm32"))] +type BoxFuture<'a, T> = futures::future::BoxFuture<'a, T>; + +/// +#[cfg(target_arch = "wasm32")] +type BoxFuture<'a, T> = futures::future::LocalBoxFuture<'a, T>; + +/// MaybeSend +/// +/// Which is Send for most platforms but WASM. +#[cfg(not(target_arch = "wasm32"))] +pub trait MaybeSend: Send {} + +#[cfg(target_arch = "wasm32")] +pub trait MaybeSend {} + +/// Autoimplement MaybeSend for T +#[cfg(not(target_arch = "wasm32"))] +impl MaybeSend for T {} + +#[cfg(target_arch = "wasm32")] +impl MaybeSend for T {} + +/// Size of proofs to send to avoid hitting the mint limit. +const BATCH_PROOF_SIZE: usize = 100; + +impl Wallet { + /// Perform an async task, which is assumed to be a foreign mint call that can fail. If fails, + /// the proofs used in the request are set as unspent, then they are swapped, as they are + /// believed to be already shown to the mint + #[inline(always)] + pub(crate) fn try_proof_operation_or_reclaim<'a, F, R>( + &'a self, + inputs: Proofs, + f: F, + ) -> BoxFuture<'a, F::Output> + where + F: Future> + MaybeSend + 'a, + R: MaybeSend + Sync + 'a, + { + Box::pin(async move { + match f.await { + Ok(r) => Ok(r), + Err(err) => { + tracing::error!( + "Http operation failed with \"{}\", revering {} proofs states to UNSPENT", + err, + inputs.len() + ); + + let swap_reverted_proofs = self + .in_error_swap_reverted_proofs + .compare_exchange( + false, + true, + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + ) + .is_ok(); + + if swap_reverted_proofs { + tracing::error!( + "Attempting to swap exposed {} proofs to new proofs", + inputs.len() + ); + for proofs in inputs.chunks(BATCH_PROOF_SIZE) { + if let Err(inner_err) = self.reclaim_unspent(proofs.to_owned()).await { + println!( + "Failed to swap exposed proofs ({}), updating local database instead", inner_err + ); + tracing::warn!( + "Failed to swap exposed proofs ({}), updating local database instead", inner_err + ); + + let _ = self + .localstore + .update_proofs_state( + proofs + .iter() + .map(|x| x.y()) + .collect::, _>>()?, + State::Unspent, + ) + .await + .inspect_err(|err| { + tracing::error!("Failed err update_proofs_state {}", err) + }); + } + } + + self.in_error_swap_reverted_proofs + .store(false, std::sync::atomic::Ordering::SeqCst); + } + + Err(err) + } + } + }) + } +} diff --git a/crates/cdk/src/wallet/swap.rs b/crates/cdk/src/wallet/swap.rs index 6e52eeb7..71a538b5 100644 --- a/crates/cdk/src/wallet/swap.rs +++ b/crates/cdk/src/wallet/swap.rs @@ -37,7 +37,12 @@ impl Wallet { ) .await?; - let swap_response = self.client.post_swap(pre_swap.swap_request).await?; + let swap_response = self + .try_proof_operation_or_reclaim( + pre_swap.swap_request.inputs().clone(), + self.client.post_swap(pre_swap.swap_request), + ) + .await?; let active_keyset_id = pre_swap.pre_mint_secrets.keyset_id; let fee_and_amounts = self