소스 검색

feat: implement wallet sagas (#1524)

tsk 6 일 전
부모
커밋
47c7bd4ab4
100개의 변경된 파일10795개의 추가작업 그리고 3044개의 파일을 삭제
  1. 9 0
      crates/cashu/src/amount.rs
  2. 10 1
      crates/cashu/src/nuts/nut08.rs
  3. 15 19
      crates/cdk-cli/src/sub_commands/check_pending.rs
  4. 38 14
      crates/cdk-cli/src/sub_commands/melt.rs
  5. 15 8
      crates/cdk-cli/src/sub_commands/mint.rs
  6. 2 1
      crates/cdk-cli/src/sub_commands/npubcash.rs
  7. 1 1
      crates/cdk-cli/src/sub_commands/pending_mints.rs
  8. 7 13
      crates/cdk-cli/src/sub_commands/send.rs
  9. 4 8
      crates/cdk-cli/src/sub_commands/update_mint_url.rs
  10. 3 4
      crates/cdk-cli/src/utils.rs
  11. 1 1
      crates/cdk-common/Cargo.toml
  12. 108 199
      crates/cdk-common/src/common.rs
  13. 10 0
      crates/cdk-common/src/database/mod.rs
  14. 59 8
      crates/cdk-common/src/database/wallet/mod.rs
  15. 407 4
      crates/cdk-common/src/database/wallet/test/mod.rs
  16. 216 3
      crates/cdk-common/src/error.rs
  17. 2 0
      crates/cdk-common/src/lib.rs
  18. 1 0
      crates/cdk-common/src/task.rs
  19. 285 13
      crates/cdk-common/src/wallet/mod.rs
  20. 55 0
      crates/cdk-common/src/wallet/saga/issue.rs
  21. 63 0
      crates/cdk-common/src/wallet/saga/melt.rs
  22. 247 0
      crates/cdk-common/src/wallet/saga/mod.rs
  23. 55 0
      crates/cdk-common/src/wallet/saga/receive.rs
  24. 57 0
      crates/cdk-common/src/wallet/saga/send.rs
  25. 55 0
      crates/cdk-common/src/wallet/saga/swap.rs
  26. 490 35
      crates/cdk-ffi/src/database.rs
  27. 90 28
      crates/cdk-ffi/src/multi_mint_wallet.rs
  28. 1 1
      crates/cdk-ffi/src/postgres.rs
  29. 2 2
      crates/cdk-ffi/src/sqlite.rs
  30. 17 0
      crates/cdk-ffi/src/types/proof.rs
  31. 32 0
      crates/cdk-ffi/src/types/quote.rs
  32. 6 0
      crates/cdk-ffi/src/types/transaction.rs
  33. 280 77
      crates/cdk-ffi/src/types/wallet.rs
  34. 79 78
      crates/cdk-ffi/src/wallet.rs
  35. 3 2
      crates/cdk-integration-tests/src/cli.rs
  36. 5 2
      crates/cdk-integration-tests/src/init_pure_tests.rs
  37. 57 2
      crates/cdk-integration-tests/src/lib.rs
  38. 30 13
      crates/cdk-integration-tests/tests/async_melt.rs
  39. 89 33
      crates/cdk-integration-tests/tests/bolt12.rs
  40. 12 8
      crates/cdk-integration-tests/tests/fake_auth.rs
  41. 239 95
      crates/cdk-integration-tests/tests/fake_wallet.rs
  42. 18 6
      crates/cdk-integration-tests/tests/ffi_minting_integration.rs
  43. 62 26
      crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs
  44. 9 7
      crates/cdk-integration-tests/tests/integration_tests_pure.rs
  45. 73 13
      crates/cdk-integration-tests/tests/multi_mint_wallet.rs
  46. 78 32
      crates/cdk-integration-tests/tests/regtest.rs
  47. 15 7
      crates/cdk-integration-tests/tests/test_fees.rs
  48. 52 21
      crates/cdk-integration-tests/tests/test_swap_flow.rs
  49. 524 0
      crates/cdk-integration-tests/tests/wallet_saga.rs
  50. 2 0
      crates/cdk-npubcash/src/types.rs
  51. 457 30
      crates/cdk-redb/src/wallet/mod.rs
  52. 45 0
      crates/cdk-sql-common/src/wallet/migrations/postgres/20251228000000_add_wallet_operations.sql
  53. 45 0
      crates/cdk-sql-common/src/wallet/migrations/sqlite/20251228000000_add_wallet_operations.sql
  54. 541 240
      crates/cdk-sql-common/src/wallet/mod.rs
  55. 12 2
      crates/cdk-sqlite/src/wallet/mod.rs
  56. 16 0
      crates/cdk/Cargo.toml
  57. 2 2
      crates/cdk/README.md
  58. 10 3
      crates/cdk/examples/auth_wallet.rs
  59. 33 16
      crates/cdk/examples/bip353.rs
  60. 60 26
      crates/cdk/examples/human_readable_payment.rs
  61. 27 5
      crates/cdk/examples/melt-token.rs
  62. 12 4
      crates/cdk/examples/mint-token-bolt12-with-custom-http.rs
  63. 12 4
      crates/cdk/examples/mint-token-bolt12-with-stream.rs
  64. 6 2
      crates/cdk/examples/mint-token-bolt12.rs
  65. 6 2
      crates/cdk/examples/mint-token.rs
  66. 166 0
      crates/cdk/examples/multi-mint-wallet.rs
  67. 6 2
      crates/cdk/examples/p2pk.rs
  68. 4 2
      crates/cdk/examples/payment_request.rs
  69. 4 2
      crates/cdk/examples/proof-selection.rs
  70. 78 0
      crates/cdk/examples/receive-token.rs
  71. 109 0
      crates/cdk/examples/restore-wallet.rs
  72. 203 0
      crates/cdk/examples/revoke_send.rs
  73. 6 2
      crates/cdk/examples/wallet.rs
  74. 4 2
      crates/cdk/src/mint/melt/melt_saga/tests.rs
  75. 11 16
      crates/cdk/src/mint/swap/swap_saga/mod.rs
  76. 3 3
      crates/cdk/src/mint/swap/swap_saga/tests.rs
  77. 1 1
      crates/cdk/src/wallet/auth/auth_wallet.rs
  78. 0 1
      crates/cdk/src/wallet/builder.rs
  79. 0 378
      crates/cdk/src/wallet/issue/bolt11.rs
  80. 0 281
      crates/cdk/src/wallet/issue/bolt12.rs
  81. 0 247
      crates/cdk/src/wallet/issue/custom.rs
  82. 339 40
      crates/cdk/src/wallet/issue/mod.rs
  83. 221 0
      crates/cdk/src/wallet/issue/saga/compensation.rs
  84. 493 0
      crates/cdk/src/wallet/issue/saga/mod.rs
  85. 543 0
      crates/cdk/src/wallet/issue/saga/resume.rs
  86. 49 0
      crates/cdk/src/wallet/issue/saga/state.rs
  87. 8 448
      crates/cdk/src/wallet/melt/bolt11.rs
  88. 4 32
      crates/cdk/src/wallet/melt/bolt12.rs
  89. 9 2
      crates/cdk/src/wallet/melt/custom.rs
  90. 1 1
      crates/cdk/src/wallet/melt/melt_lightning_address.rs
  91. 662 36
      crates/cdk/src/wallet/melt/mod.rs
  92. 256 0
      crates/cdk/src/wallet/melt/saga/compensation.rs
  93. 1033 0
      crates/cdk/src/wallet/melt/saga/mod.rs
  94. 561 0
      crates/cdk/src/wallet/melt/saga/resume.rs
  95. 83 0
      crates/cdk/src/wallet/melt/saga/state.rs
  96. 6 7
      crates/cdk/src/wallet/mod.rs
  97. 615 66
      crates/cdk/src/wallet/multi_mint_wallet.rs
  98. 2 1
      crates/cdk/src/wallet/payment_request.rs
  99. 41 45
      crates/cdk/src/wallet/proofs.rs
  100. 0 308
      crates/cdk/src/wallet/receive.rs

+ 9 - 0
crates/cashu/src/amount.rs

@@ -339,6 +339,15 @@ impl Amount<()> {
             .map(|v| Amount { value: v, unit: () })
     }
 
+    /// Subtracts `other` from `self`, returning zero if the result would be negative.
+    pub fn saturating_sub(self, other: Self) -> Self {
+        if other > self {
+            Self::ZERO
+        } else {
+            self - other
+        }
+    }
+
     /// Try sum to check for overflow
     pub fn try_sum<I>(iter: I) -> Result<Self, Error>
     where

+ 10 - 1
crates/cashu/src/nuts/nut08.rs

@@ -2,7 +2,7 @@
 //!
 //! <https://github.com/cashubtc/nuts/blob/main/08.md>
 
-use super::nut05::MeltRequest;
+use super::nut05::{MeltQuoteCustomResponse, MeltRequest};
 use super::nut23::MeltQuoteBolt11Response;
 use crate::Amount;
 
@@ -23,3 +23,12 @@ impl<Q> MeltQuoteBolt11Response<Q> {
             .and_then(|o| Amount::try_sum(o.iter().map(|proof| proof.amount)).ok())
     }
 }
+
+impl<Q> MeltQuoteCustomResponse<Q> {
+    /// Total change [`Amount`]
+    pub fn change_amount(&self) -> Option<Amount> {
+        self.change
+            .as_ref()
+            .and_then(|o| Amount::try_sum(o.iter().map(|proof| proof.amount)).ok())
+    }
+}

+ 15 - 19
crates/cdk-cli/src/sub_commands/check_pending.rs

@@ -1,6 +1,6 @@
 use anyhow::Result;
-use cdk::nuts::nut00::ProofsMethods;
 use cdk::wallet::multi_mint_wallet::MultiMintWallet;
+use cdk::Amount;
 
 pub async fn check_pending(multi_mint_wallet: &MultiMintWallet) -> Result<()> {
     let wallets = multi_mint_wallet.get_wallets().await;
@@ -9,24 +9,20 @@ pub async fn check_pending(multi_mint_wallet: &MultiMintWallet) -> Result<()> {
         let mint_url = wallet.mint_url.clone();
         println!("{i}: {mint_url}");
 
-        // Get all pending proofs
-        let pending_proofs = wallet.get_pending_proofs().await?;
-        if pending_proofs.is_empty() {
-            println!("No pending proofs found");
-            continue;
-        }
-
-        println!(
-            "Found {} pending proofs with {} {}",
-            pending_proofs.len(),
-            pending_proofs.total_amount()?,
-            wallet.unit
-        );
-
-        // Try to reclaim any proofs that are no longer pending
-        match wallet.reclaim_unspent(pending_proofs).await {
-            Ok(()) => println!("Successfully reclaimed pending proofs"),
-            Err(e) => println!("Error reclaimed pending proofs: {e}"),
+        // Check all orphaned pending proofs (not managed by active sagas)
+        // This function queries the mint and marks spent proofs accordingly
+        match wallet.check_all_pending_proofs().await {
+            Ok(pending_amount) => {
+                if pending_amount == Amount::ZERO {
+                    println!("No orphaned pending proofs found");
+                } else {
+                    println!(
+                        "Checked pending proofs: {} {} still pending",
+                        pending_amount, wallet.unit
+                    );
+                }
+            }
+            Err(e) => println!("Error checking pending proofs: {e}"),
         }
     }
     Ok(())

+ 38 - 14
crates/cdk-cli/src/sub_commands/melt.rs

@@ -3,7 +3,7 @@ use std::str::FromStr;
 use anyhow::{bail, Result};
 use cdk::amount::{amount_for_offer, Amount, MSAT_IN_SAT};
 use cdk::mint_url::MintUrl;
-use cdk::nuts::{CurrencyUnit, MeltOptions};
+use cdk::nuts::{CurrencyUnit, MeltOptions, PaymentMethod};
 use cdk::wallet::MultiMintWallet;
 use cdk::Bolt11Invoice;
 use clap::{Args, ValueEnum};
@@ -217,12 +217,14 @@ pub async fn pay(
         for (mint_url, melted) in results {
             println!(
                 "  {} - Paid: {}, Fee: {}",
-                mint_url, melted.amount, melted.fee_paid
+                mint_url,
+                melted.amount(),
+                melted.fee_paid()
             );
-            total_paid += melted.amount;
-            total_fees += melted.fee_paid;
+            total_paid += melted.amount();
+            total_fees += melted.fee_paid();
 
-            if let Some(preimage) = melted.preimage {
+            if let Some(preimage) = melted.payment_proof() {
                 println!("    Preimage: {}", preimage);
             }
         }
@@ -271,9 +273,11 @@ pub async fn pay(
 
                 println!(
                     "Payment successful: state={}, amount={}, fee_paid={}",
-                    melted.state, melted.amount, melted.fee_paid
+                    melted.state(),
+                    melted.amount(),
+                    melted.fee_paid()
                 );
-                if let Some(preimage) = melted.preimage {
+                if let Some(preimage) = melted.payment_proof() {
                     println!("Payment preimage: {}", preimage);
                 }
             }
@@ -316,7 +320,9 @@ pub async fn pay(
                     .ok_or_else(|| anyhow::anyhow!("Mint {} not found", mint_url))?;
 
                 // Get melt quote for BOLT12
-                let quote = wallet.melt_bolt12_quote(offer_str, options).await?;
+                let quote = wallet
+                    .melt_quote(PaymentMethod::BOLT12, offer_str, options, None)
+                    .await?;
 
                 // Display quote info
                 println!("Melt quote created:");
@@ -327,12 +333,21 @@ pub async fn pay(
                 println!("  Expiry: {}", quote.expiry);
 
                 // Execute the melt
-                let melted = wallet.melt(&quote.id).await?;
+                let prepared = wallet
+                    .prepare_melt(&quote.id, std::collections::HashMap::new())
+                    .await?;
+                println!(
+                    "Prepared melt - Amount: {}, Fee: {}",
+                    prepared.amount(),
+                    prepared.total_fee()
+                );
+                let confirmed = prepared.confirm().await?;
                 println!(
                     "Payment successful: Paid {} with fee {}",
-                    melted.amount, melted.fee_paid
+                    confirmed.amount(),
+                    confirmed.fee_paid()
                 );
-                if let Some(preimage) = melted.preimage {
+                if let Some(preimage) = confirmed.payment_proof() {
                     println!("Payment preimage: {}", preimage);
                 }
             }
@@ -383,12 +398,21 @@ pub async fn pay(
                 println!("  Expiry: {}", quote.expiry);
 
                 // Execute the melt
-                let melted = wallet.melt(&quote.id).await?;
+                let prepared = wallet
+                    .prepare_melt(&quote.id, std::collections::HashMap::new())
+                    .await?;
+                println!(
+                    "Prepared melt - Amount: {}, Fee: {}",
+                    prepared.amount(),
+                    prepared.total_fee()
+                );
+                let confirmed = prepared.confirm().await?;
                 println!(
                     "Payment successful: Paid {} with fee {}",
-                    melted.amount, melted.fee_paid
+                    confirmed.amount(),
+                    confirmed.fee_paid()
                 );
-                if let Some(preimage) = melted.preimage {
+                if let Some(preimage) = confirmed.payment_proof() {
                     println!("Payment preimage: {}", preimage);
                 }
             }

+ 15 - 8
crates/cdk-cli/src/sub_commands/mint.rs

@@ -56,7 +56,14 @@ pub async fn mint(
                 let amount = sub_command_args
                     .amount
                     .ok_or(anyhow!("Amount must be defined"))?;
-                let quote = wallet.mint_quote(Amount::from(amount), description).await?;
+                let quote = wallet
+                    .mint_quote(
+                        PaymentMethod::BOLT11,
+                        Some(Amount::from(amount)),
+                        description,
+                        None,
+                    )
+                    .await?;
 
                 println!(
                     "Quote: id={}, state={}, amount={}, expiry={}",
@@ -79,7 +86,12 @@ pub async fn mint(
                         .map_or("none".to_string(), |b| b.to_string())
                 );
                 let quote = wallet
-                    .mint_bolt12_quote(amount.map(|a| a.into()), description)
+                    .mint_quote(
+                        payment_method.clone(),
+                        amount.map(|a| a.into()),
+                        description,
+                        None,
+                    )
                     .await?;
 
                 println!(
@@ -103,12 +115,7 @@ pub async fn mint(
                         .map_or("none".to_string(), |b| b.to_string())
                 );
                 let quote = wallet
-                    .mint_quote_unified(
-                        amount.map(|a| a.into()),
-                        payment_method.clone(),
-                        None,
-                        None,
-                    )
+                    .mint_quote(payment_method.clone(), amount.map(|a| a.into()), None, None)
                     .await?;
 
                 println!(

+ 2 - 1
crates/cdk-cli/src/sub_commands/npubcash.rs

@@ -1,4 +1,5 @@
 use std::str::FromStr;
+use std::sync::Arc;
 use std::time::Duration;
 
 use anyhow::{bail, Result};
@@ -14,7 +15,7 @@ use nostr_sdk::ToBech32;
 async fn get_wallet_for_mint(
     multi_mint_wallet: &MultiMintWallet,
     mint_url_str: &str,
-) -> Result<Wallet> {
+) -> Result<Arc<Wallet>> {
     let mint_url = MintUrl::from_str(mint_url_str)?;
 
     // Check if wallet exists for this mint

+ 1 - 1
crates/cdk-cli/src/sub_commands/pending_mints.rs

@@ -2,7 +2,7 @@ use anyhow::Result;
 use cdk::wallet::MultiMintWallet;
 
 pub async fn mint_pending(multi_mint_wallet: &MultiMintWallet) -> Result<()> {
-    let amount = multi_mint_wallet.check_all_mint_quotes(None).await?;
+    let amount = multi_mint_wallet.mint_unissued_quotes(None).await?;
 
     println!("Amount: {amount}");
 

+ 7 - 13
crates/cdk-cli/src/sub_commands/send.rs

@@ -273,7 +273,7 @@ pub async fn send(
         .collect();
     let excluded_mints = excluded_mints?;
 
-    // Prepare and confirm the send based on mint selection
+    // Send based on mint selection
     let token = if let Some(specific_mint) = selected_mint {
         // User selected a specific mint
         let multi_mint_options = cdk::wallet::multi_mint_wallet::MultiMintSendOptions {
@@ -284,12 +284,9 @@ pub async fn send(
             send_options: send_options.clone(),
         };
 
-        let prepared = multi_mint_wallet
-            .prepare_send(specific_mint, token_amount, multi_mint_options)
-            .await?;
-
-        let memo = send_options.memo.clone();
-        prepared.confirm(memo).await?
+        multi_mint_wallet
+            .send(specific_mint, token_amount, multi_mint_options)
+            .await?
     } else {
         // User selected "Any" - find the first mint with sufficient balance
         let balances = multi_mint_wallet.get_balances().await?;
@@ -307,12 +304,9 @@ pub async fn send(
             send_options: send_options.clone(),
         };
 
-        let prepared = multi_mint_wallet
-            .prepare_send(best_mint, token_amount, multi_mint_options)
-            .await?;
-
-        let memo = send_options.memo.clone();
-        prepared.confirm(memo).await?
+        multi_mint_wallet
+            .send(best_mint, token_amount, multi_mint_options)
+            .await?
     };
 
     match sub_command_args.v3 {

+ 4 - 8
crates/cdk-cli/src/sub_commands/update_mint_url.rs

@@ -1,4 +1,4 @@
-use anyhow::{anyhow, Result};
+use anyhow::Result;
 use cdk::mint_url::MintUrl;
 use cdk::wallet::MultiMintWallet;
 use clap::Args;
@@ -20,13 +20,9 @@ pub async fn update_mint_url(
         new_mint_url,
     } = sub_command_args;
 
-    let mut wallet = multi_mint_wallet
-        .get_wallet(&sub_command_args.old_mint_url)
-        .await
-        .ok_or(anyhow!("Unknown mint url"))?
-        .clone();
-
-    wallet.update_mint_url(new_mint_url.clone()).await?;
+    multi_mint_wallet
+        .update_mint_url(old_mint_url, new_mint_url.clone())
+        .await?;
 
     println!("Mint Url changed from {old_mint_url} to {new_mint_url}");
 

+ 3 - 4
crates/cdk-cli/src/utils.rs

@@ -29,17 +29,16 @@ where
 pub async fn get_or_create_wallet(
     multi_mint_wallet: &MultiMintWallet,
     mint_url: &MintUrl,
-) -> Result<cdk::wallet::Wallet> {
+) -> Result<std::sync::Arc<cdk::wallet::Wallet>> {
     match multi_mint_wallet.get_wallet(mint_url).await {
-        Some(wallet) => Ok(wallet.clone()),
+        Some(wallet) => Ok(wallet),
         None => {
             tracing::debug!("Wallet does not exist creating..");
             multi_mint_wallet.add_mint(mint_url.clone()).await?;
             Ok(multi_mint_wallet
                 .get_wallet(mint_url)
                 .await
-                .expect("Wallet should exist after adding mint")
-                .clone())
+                .expect("Wallet should exist after adding mint"))
         }
     }
 }

+ 1 - 1
crates/cdk-common/Cargo.toml

@@ -15,7 +15,7 @@ default = ["mint", "wallet"]
 swagger = ["dep:utoipa", "cashu/swagger"]
 test = []
 bench = []
-wallet = ["cashu/wallet"]
+wallet = ["cashu/wallet", "dep:uuid"]
 mint = ["cashu/mint", "dep:uuid"]
 auth = ["cashu/auth"]
 nostr = ["wallet", "cashu/nostr"]

+ 108 - 199
crates/cdk-common/src/common.rs

@@ -3,34 +3,55 @@
 use serde::{Deserialize, Serialize};
 
 use crate::error::Error;
-use crate::mint_url::MintUrl;
 use crate::nuts::nut00::ProofsMethods;
-use crate::nuts::{
-    CurrencyUnit, MeltQuoteState, PaymentMethod, Proof, Proofs, PublicKey, SpendingConditions,
-    State,
-};
+use crate::nuts::{CurrencyUnit, MeltQuoteState, PaymentMethod, Proofs};
+// Re-export ProofInfo from wallet module for backwards compatibility
+#[cfg(feature = "wallet")]
+pub use crate::wallet::ProofInfo;
 use crate::Amount;
 
-/// Melt response with proofs
-#[derive(Debug, Clone, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
-pub struct Melted {
+/// Result of a finalized melt operation
+#[derive(Clone, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub struct FinalizedMelt {
+    /// Quote ID
+    quote_id: String,
     /// State of quote
-    pub state: MeltQuoteState,
-    /// Preimage of melt payment
-    pub preimage: Option<String>,
+    state: MeltQuoteState,
+    /// Payment proof (e.g., Lightning preimage)
+    payment_proof: Option<String>,
     /// Melt change
-    pub change: Option<Proofs>,
+    change: Option<Proofs>,
     /// Melt amount
-    pub amount: Amount,
+    amount: Amount,
     /// Fee paid
-    pub fee_paid: Amount,
+    fee_paid: Amount,
 }
 
-impl Melted {
-    /// Create new [`Melted`]
+impl FinalizedMelt {
+    /// Create new [`FinalizedMelt`]
+    pub fn new(
+        quote_id: String,
+        state: MeltQuoteState,
+        payment_proof: Option<String>,
+        amount: Amount,
+        fee_paid: Amount,
+        change: Option<Proofs>,
+    ) -> Self {
+        Self {
+            quote_id,
+            state,
+            payment_proof,
+            change,
+            amount,
+            fee_paid,
+        }
+    }
+
+    /// Create new [`FinalizedMelt`] calculating fee from proofs
     pub fn from_proofs(
+        quote_id: String,
         state: MeltQuoteState,
-        preimage: Option<String>,
+        payment_proof: Option<String>,
         quote_amount: Amount,
         proofs: Proofs,
         change_proofs: Option<Proofs>,
@@ -57,20 +78,64 @@ impl Melted {
             .ok_or(Error::AmountOverflow)?;
 
         Ok(Self {
+            quote_id,
             state,
-            preimage,
+            payment_proof,
             change: change_proofs,
             amount: quote_amount,
             fee_paid,
         })
     }
 
-    /// Total amount melted
+    /// Get the quote ID
+    #[inline]
+    pub fn quote_id(&self) -> &str {
+        &self.quote_id
+    }
+
+    /// Get the state of the melt
+    #[inline]
+    pub fn state(&self) -> MeltQuoteState {
+        self.state
+    }
+
+    /// Get the payment proof (e.g., Lightning preimage)
+    #[inline]
+    pub fn payment_proof(&self) -> Option<&str> {
+        self.payment_proof.as_deref()
+    }
+
+    /// Get the change proofs
+    #[inline]
+    pub fn change(&self) -> Option<&Proofs> {
+        self.change.as_ref()
+    }
+
+    /// Consume self and return the change proofs
+    #[inline]
+    pub fn into_change(self) -> Option<Proofs> {
+        self.change
+    }
+
+    /// Get the amount melted
+    #[inline]
+    pub fn amount(&self) -> Amount {
+        self.amount
+    }
+
+    /// Get the fee paid
+    #[inline]
+    pub fn fee_paid(&self) -> Amount {
+        self.fee_paid
+    }
+
+    /// Total amount melted (amount + fee)
     ///
     /// # Panics
     ///
     /// Panics if the sum of `amount` and `fee_paid` overflows. This should not
     /// happen as the fee is validated when calculated.
+    #[inline]
     pub fn total_amount(&self) -> Amount {
         self.amount
             .checked_add(self.fee_paid)
@@ -78,87 +143,14 @@ impl Melted {
     }
 }
 
-/// Prooinfo
-#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ProofInfo {
-    /// Proof
-    pub proof: Proof,
-    /// y
-    pub y: PublicKey,
-    /// Mint Url
-    pub mint_url: MintUrl,
-    /// Proof State
-    pub state: State,
-    /// Proof Spending Conditions
-    pub spending_condition: Option<SpendingConditions>,
-    /// Unit
-    pub unit: CurrencyUnit,
-}
-
-impl ProofInfo {
-    /// Create new [`ProofInfo`]
-    pub fn new(
-        proof: Proof,
-        mint_url: MintUrl,
-        state: State,
-        unit: CurrencyUnit,
-    ) -> Result<Self, Error> {
-        let y = proof.y()?;
-
-        let spending_condition: Option<SpendingConditions> = (&proof.secret).try_into().ok();
-
-        Ok(Self {
-            proof,
-            y,
-            mint_url,
-            state,
-            spending_condition,
-            unit,
-        })
-    }
-
-    /// Check if [`Proof`] matches conditions
-    pub fn matches_conditions(
-        &self,
-        mint_url: &Option<MintUrl>,
-        unit: &Option<CurrencyUnit>,
-        state: &Option<Vec<State>>,
-        spending_conditions: &Option<Vec<SpendingConditions>>,
-    ) -> bool {
-        if let Some(mint_url) = mint_url {
-            if mint_url.ne(&self.mint_url) {
-                return false;
-            }
-        }
-
-        if let Some(unit) = unit {
-            if unit.ne(&self.unit) {
-                return false;
-            }
-        }
-
-        if let Some(state) = state {
-            if !state.contains(&self.state) {
-                return false;
-            }
-        }
-
-        if let Some(spending_conditions) = spending_conditions {
-            match &self.spending_condition {
-                None => {
-                    if !spending_conditions.is_empty() {
-                        return false;
-                    }
-                }
-                Some(s) => {
-                    if !spending_conditions.contains(s) {
-                        return false;
-                    }
-                }
-            }
-        }
-
-        true
+impl std::fmt::Debug for FinalizedMelt {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("FinalizedMelt")
+            .field("quote_id", &self.quote_id)
+            .field("state", &self.state)
+            .field("amount", &self.amount)
+            .field("fee_paid", &self.fee_paid)
+            .finish()
     }
 }
 
@@ -208,16 +200,13 @@ impl Default for QuoteTTL {
 mod tests {
     use std::str::FromStr;
 
-    use cashu::SecretKey;
-
-    use super::{Melted, ProofInfo};
-    use crate::mint_url::MintUrl;
-    use crate::nuts::{CurrencyUnit, Id, Proof, PublicKey, SpendingConditions, State};
+    use super::FinalizedMelt;
+    use crate::nuts::{Id, Proof, PublicKey};
     use crate::secret::Secret;
     use crate::Amount;
 
     #[test]
-    fn test_melted() {
+    fn test_finalized_melt() {
         let keyset_id = Id::from_str("00deadbeef123456").unwrap();
         let proof = Proof::new(
             Amount::from(64),
@@ -228,7 +217,8 @@ mod tests {
             )
             .unwrap(),
         );
-        let melted = Melted::from_proofs(
+        let finalized = FinalizedMelt::from_proofs(
+            "test_quote_id".to_string(),
             super::MeltQuoteState::Paid,
             Some("preimage".to_string()),
             Amount::from(64),
@@ -236,13 +226,14 @@ mod tests {
             None,
         )
         .unwrap();
-        assert_eq!(melted.amount, Amount::from(64));
-        assert_eq!(melted.fee_paid, Amount::ZERO);
-        assert_eq!(melted.total_amount(), Amount::from(64));
+        assert_eq!(finalized.quote_id(), "test_quote_id");
+        assert_eq!(finalized.amount(), Amount::from(64));
+        assert_eq!(finalized.fee_paid(), Amount::ZERO);
+        assert_eq!(finalized.total_amount(), Amount::from(64));
     }
 
     #[test]
-    fn test_melted_with_change() {
+    fn test_finalized_melt_with_change() {
         let keyset_id = Id::from_str("00deadbeef123456").unwrap();
         let proof = Proof::new(
             Amount::from(64),
@@ -262,7 +253,8 @@ mod tests {
             )
             .unwrap(),
         );
-        let melted = Melted::from_proofs(
+        let finalized = FinalizedMelt::from_proofs(
+            "test_quote_id".to_string(),
             super::MeltQuoteState::Paid,
             Some("preimage".to_string()),
             Amount::from(31),
@@ -270,93 +262,10 @@ mod tests {
             Some(vec![change_proof.clone()]),
         )
         .unwrap();
-        assert_eq!(melted.amount, Amount::from(31));
-        assert_eq!(melted.fee_paid, Amount::from(1));
-        assert_eq!(melted.total_amount(), Amount::from(32));
-    }
-
-    #[test]
-    fn test_matches_conditions() {
-        let keyset_id = Id::from_str("00deadbeef123456").unwrap();
-        let proof = Proof::new(
-            Amount::from(64),
-            keyset_id,
-            Secret::new("test_secret"),
-            PublicKey::from_hex(
-                "02deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
-            )
-            .unwrap(),
-        );
-
-        let mint_url = MintUrl::from_str("https://example.com").unwrap();
-        let proof_info =
-            ProofInfo::new(proof, mint_url.clone(), State::Unspent, CurrencyUnit::Sat).unwrap();
-
-        // Test matching mint_url
-        assert!(proof_info.matches_conditions(&Some(mint_url.clone()), &None, &None, &None));
-        assert!(!proof_info.matches_conditions(
-            &Some(MintUrl::from_str("https://different.com").unwrap()),
-            &None,
-            &None,
-            &None
-        ));
-
-        // Test matching unit
-        assert!(proof_info.matches_conditions(&None, &Some(CurrencyUnit::Sat), &None, &None));
-        assert!(!proof_info.matches_conditions(&None, &Some(CurrencyUnit::Msat), &None, &None));
-
-        // Test matching state
-        assert!(proof_info.matches_conditions(&None, &None, &Some(vec![State::Unspent]), &None));
-        assert!(proof_info.matches_conditions(
-            &None,
-            &None,
-            &Some(vec![State::Unspent, State::Spent]),
-            &None
-        ));
-        assert!(!proof_info.matches_conditions(&None, &None, &Some(vec![State::Spent]), &None));
-
-        // Test with no conditions (should match)
-        assert!(proof_info.matches_conditions(&None, &None, &None, &None));
-
-        // Test with multiple conditions
-        assert!(proof_info.matches_conditions(
-            &Some(mint_url),
-            &Some(CurrencyUnit::Sat),
-            &Some(vec![State::Unspent]),
-            &None
-        ));
-    }
-
-    #[test]
-    fn test_matches_conditions_with_spending_conditions() {
-        // This test would need to be expanded with actual SpendingConditions
-        // implementation, but we can test the basic case where no spending
-        // conditions are present
-
-        let keyset_id = Id::from_str("00deadbeef123456").unwrap();
-        let proof = Proof::new(
-            Amount::from(64),
-            keyset_id,
-            Secret::new("test_secret"),
-            PublicKey::from_hex(
-                "02deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
-            )
-            .unwrap(),
-        );
-
-        let mint_url = MintUrl::from_str("https://example.com").unwrap();
-        let proof_info =
-            ProofInfo::new(proof, mint_url, State::Unspent, CurrencyUnit::Sat).unwrap();
-
-        // Test with empty spending conditions (should match when proof has none)
-        assert!(proof_info.matches_conditions(&None, &None, &None, &Some(vec![])));
-
-        // Test with non-empty spending conditions (should not match when proof has none)
-        let dummy_condition = SpendingConditions::P2PKConditions {
-            data: SecretKey::generate().public_key(),
-            conditions: None,
-        };
-        assert!(!proof_info.matches_conditions(&None, &None, &None, &Some(vec![dummy_condition])));
+        assert_eq!(finalized.quote_id(), "test_quote_id");
+        assert_eq!(finalized.amount(), Amount::from(31));
+        assert_eq!(finalized.fee_paid(), Amount::from(1));
+        assert_eq!(finalized.total_amount(), Amount::from(32));
     }
 }
 

+ 10 - 0
crates/cdk-common/src/database/mod.rs

@@ -164,6 +164,12 @@ pub enum Error {
     /// Proof not found
     #[error("Proof not found")]
     ProofNotFound,
+    /// Proof not in unspent state (may be reserved, pending, or spent)
+    #[error("Proof not in unspent state")]
+    ProofNotUnspent,
+    /// Quote is already in use by another operation
+    #[error("Quote already in use by another operation")]
+    QuoteAlreadyInUse,
     /// Invalid keyset
     #[error("Unknown or invalid keyset")]
     InvalidKeysetId,
@@ -207,6 +213,10 @@ pub enum Error {
     /// KV Store invalid key or namespace
     #[error("Invalid KV store key or namespace: {0}")]
     KVStoreInvalidKey(String),
+
+    /// Concurrent update detected
+    #[error("Concurrent update detected")]
+    ConcurrentUpdate,
 }
 
 #[cfg(feature = "mint")]

+ 59 - 8
crates/cdk-common/src/database/wallet/mod.rs

@@ -7,13 +7,12 @@ use async_trait::async_trait;
 use cashu::KeySet;
 
 use super::Error;
-use crate::common::ProofInfo;
 use crate::mint_url::MintUrl;
 use crate::nuts::{
     CurrencyUnit, Id, KeySetInfo, Keys, MintInfo, PublicKey, SpendingConditions, State,
 };
 use crate::wallet::{
-    self, MintQuote as WalletMintQuote, Transaction, TransactionDirection, TransactionId,
+    self, MintQuote as WalletMintQuote, ProofInfo, Transaction, TransactionDirection, TransactionId,
 };
 
 #[cfg(feature = "test")]
@@ -92,27 +91,27 @@ where
     ) -> Result<Vec<Transaction>, Err>;
 
     /// Update the proofs in storage by adding new proofs or removing proofs by
-    /// their Y value (without transaction)
+    /// their Y value
     async fn update_proofs(
         &self,
         added: Vec<ProofInfo>,
         removed_ys: Vec<PublicKey>,
     ) -> Result<(), Err>;
 
-    /// Update proofs state in storage (without transaction)
+    /// Update proofs state in storage
     async fn update_proofs_state(&self, ys: Vec<PublicKey>, state: State) -> Result<(), Err>;
 
-    /// Add transaction to storage (without transaction)
+    /// Add transaction to storage
     async fn add_transaction(&self, transaction: Transaction) -> Result<(), Err>;
 
-    /// Update mint url (without transaction)
+    /// Update mint url
     async fn update_mint_url(
         &self,
         old_mint_url: MintUrl,
         new_mint_url: MintUrl,
     ) -> Result<(), Err>;
 
-    /// Atomically increment Keyset counter and return new value (without transaction)
+    /// Atomically increment Keyset counter and return new value
     async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result<u32, Err>;
 
     /// Add Mint to storage
@@ -149,7 +148,59 @@ where
     /// Remove transaction from storage
     async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), Err>;
 
-    // KV Store write methods (non-transactional)
+    /// Add a wallet saga to storage.
+    ///
+    /// The saga should be created with `WalletSaga::new()` which initializes
+    /// `version = 0`. This is the starting point for optimistic locking.
+    async fn add_saga(&self, saga: wallet::WalletSaga) -> Result<(), Err>;
+
+    /// Get a wallet saga by ID.
+    async fn get_saga(&self, id: &uuid::Uuid) -> Result<Option<wallet::WalletSaga>, Err>;
+
+    /// Update a wallet saga with optimistic locking.
+    ///
+    /// Returns `Ok(true)` if the update succeeded (version match), or `Ok(false)`
+    /// if another instance modified the saga first (version mismatch).
+    async fn update_saga(&self, saga: wallet::WalletSaga) -> Result<bool, Err>;
+
+    /// Delete a wallet saga.
+    async fn delete_saga(&self, id: &uuid::Uuid) -> Result<(), Err>;
+
+    /// Get all incomplete sagas.
+    async fn get_incomplete_sagas(&self) -> Result<Vec<wallet::WalletSaga>, Err>;
+
+    /// Reserve proofs for an operation
+    async fn reserve_proofs(
+        &self,
+        ys: Vec<PublicKey>,
+        operation_id: &uuid::Uuid,
+    ) -> Result<(), Err>;
+
+    /// Release proofs reserved by an operation
+    async fn release_proofs(&self, operation_id: &uuid::Uuid) -> Result<(), Err>;
+
+    /// Get proofs reserved by an operation
+    async fn get_reserved_proofs(&self, operation_id: &uuid::Uuid) -> Result<Vec<ProofInfo>, Err>;
+
+    /// Reserve a melt quote for an operation.
+    async fn reserve_melt_quote(
+        &self,
+        quote_id: &str,
+        operation_id: &uuid::Uuid,
+    ) -> Result<(), Err>;
+
+    /// Release a melt quote reserved by an operation.
+    async fn release_melt_quote(&self, operation_id: &uuid::Uuid) -> Result<(), Err>;
+
+    /// Reserve a mint quote for an operation.
+    async fn reserve_mint_quote(
+        &self,
+        quote_id: &str,
+        operation_id: &uuid::Uuid,
+    ) -> Result<(), Err>;
+
+    /// Release a mint quote reserved by an operation.
+    async fn release_mint_quote(&self, operation_id: &uuid::Uuid) -> Result<(), Err>;
 
     /// Read a value from the key-value store
     async fn kv_read(

+ 407 - 4
crates/cdk-common/src/database/wallet/test/mod.rs

@@ -12,13 +12,15 @@ use std::time::{SystemTime, UNIX_EPOCH};
 
 use cashu::nut00::KnownMethod;
 use cashu::secret::Secret;
-use cashu::{Amount, CurrencyUnit, SecretKey};
+use cashu::{Amount, CurrencyUnit, MeltQuoteState, MintQuoteState, SecretKey};
 
 use super::*;
-use crate::common::ProofInfo;
 use crate::mint_url::MintUrl;
 use crate::nuts::{Id, KeySetInfo, Keys, MintInfo, Proof, State};
-use crate::wallet::{MeltQuote, MintQuote, Transaction, TransactionDirection};
+use crate::wallet::{
+    MeltQuote, MintQuote, OperationData, ProofInfo, SwapOperationData, SwapSagaState, Transaction,
+    TransactionDirection, WalletSaga, WalletSagaState,
+};
 
 static COUNTER: AtomicU64 = AtomicU64::new(0);
 
@@ -128,6 +130,8 @@ fn test_melt_quote() -> MeltQuote {
         expiry: 9999999999,
         payment_preimage: None,
         payment_method: cashu::PaymentMethod::Known(KnownMethod::Bolt11),
+        used_by_operation: None,
+        version: 0,
     }
 }
 
@@ -148,9 +152,28 @@ fn test_transaction(mint_url: MintUrl, direction: TransactionDirection) -> Trans
         payment_request: None,
         payment_proof: None,
         payment_method: None,
+        saga_id: None,
     }
 }
 
+/// Create a test wallet saga
+fn test_wallet_saga(mint_url: MintUrl) -> WalletSaga {
+    WalletSaga::new(
+        uuid::Uuid::new_v4(),
+        WalletSagaState::Swap(SwapSagaState::ProofsReserved),
+        Amount::from(1000),
+        mint_url,
+        CurrencyUnit::Sat,
+        OperationData::Swap(SwapOperationData {
+            input_amount: Amount::from(1000),
+            output_amount: Amount::from(990),
+            counter_start: Some(0),
+            counter_end: Some(10),
+            blinded_messages: None,
+        }),
+    )
+}
+
 // =============================================================================
 // Mint Management Tests
 // =============================================================================
@@ -472,6 +495,86 @@ where
     assert!(retrieved.is_none());
 }
 
+/// Test mint quote optimistic locking
+pub async fn add_mint_quote_optimistic_locking<DB>(db: DB)
+where
+    DB: Database<crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let quote = test_mint_quote(mint_url);
+
+    // 1. Initial add (insert)
+    db.add_mint_quote(quote.clone()).await.unwrap();
+
+    let retrieved = db.get_mint_quote(&quote.id).await.unwrap().unwrap();
+    assert_eq!(retrieved.version, 0);
+
+    // 2. Update (version 0 -> 1)
+    let mut quote_update_1 = quote.clone();
+    quote_update_1.state = MintQuoteState::Issued; // Change something
+    db.add_mint_quote(quote_update_1.clone()).await.unwrap();
+
+    let retrieved = db.get_mint_quote(&quote.id).await.unwrap().unwrap();
+    assert_eq!(retrieved.version, 1);
+    assert_eq!(retrieved.state, MintQuoteState::Issued);
+
+    // 3. Stale update (using version 0) - should fail
+    // quote_update_1 still has version 0
+    let mut stale_quote = quote_update_1.clone();
+    stale_quote.amount = Some(Amount::from(999));
+
+    let result = db.add_mint_quote(stale_quote).await;
+    assert!(matches!(
+        result,
+        Err(crate::database::Error::ConcurrentUpdate)
+    ));
+
+    // Verify DB wasn't changed
+    let retrieved = db.get_mint_quote(&quote.id).await.unwrap().unwrap();
+    assert_eq!(retrieved.version, 1);
+    assert_eq!(retrieved.state, MintQuoteState::Issued);
+    assert_ne!(retrieved.amount, Some(Amount::from(999)));
+}
+
+/// Test melt quote optimistic locking
+pub async fn add_melt_quote_optimistic_locking<DB>(db: DB)
+where
+    DB: Database<crate::database::Error>,
+{
+    let quote = test_melt_quote();
+
+    // 1. Initial add (insert)
+    db.add_melt_quote(quote.clone()).await.unwrap();
+
+    let retrieved = db.get_melt_quote(&quote.id).await.unwrap().unwrap();
+    assert_eq!(retrieved.version, 0);
+
+    // 2. Update (version 0 -> 1)
+    let mut quote_update_1 = quote.clone();
+    quote_update_1.state = MeltQuoteState::Paid;
+    db.add_melt_quote(quote_update_1.clone()).await.unwrap();
+
+    let retrieved = db.get_melt_quote(&quote.id).await.unwrap().unwrap();
+    assert_eq!(retrieved.version, 1);
+    assert_eq!(retrieved.state, MeltQuoteState::Paid);
+
+    // 3. Stale update (using version 0) - should fail
+    let mut stale_quote = quote_update_1.clone();
+    stale_quote.amount = Amount::from(999);
+
+    let result = db.add_melt_quote(stale_quote).await;
+    assert!(matches!(
+        result,
+        Err(crate::database::Error::ConcurrentUpdate)
+    ));
+
+    // Verify DB wasn't changed
+    let retrieved = db.get_melt_quote(&quote.id).await.unwrap().unwrap();
+    assert_eq!(retrieved.version, 1);
+    assert_eq!(retrieved.state, MeltQuoteState::Paid);
+    assert_ne!(retrieved.amount, Amount::from(999));
+}
+
 // =============================================================================
 // Proof Management Tests
 // =============================================================================
@@ -1020,6 +1123,296 @@ where
     assert_eq!(value3, Some(b"value_sub2".to_vec()));
 }
 
+// =============================================================================
+// Wallet Saga Tests
+// =============================================================================
+
+/// Test adding and retrieving a saga
+pub async fn add_and_get_saga<DB>(db: DB)
+where
+    DB: Database<crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let saga = test_wallet_saga(mint_url);
+    let saga_id = saga.id;
+
+    // Add saga
+    db.add_saga(saga.clone()).await.unwrap();
+
+    // Get saga
+    let retrieved = db.get_saga(&saga_id).await.unwrap();
+    assert!(retrieved.is_some());
+    let retrieved = retrieved.unwrap();
+    assert_eq!(retrieved.id, saga_id);
+    assert_eq!(retrieved.version, 0);
+    assert_eq!(retrieved.amount, Amount::from(1000));
+}
+
+/// Test saga optimistic locking
+pub async fn update_saga_optimistic_locking<DB>(db: DB)
+where
+    DB: Database<crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let saga = test_wallet_saga(mint_url);
+    let saga_id = saga.id;
+
+    // Add saga
+    db.add_saga(saga).await.unwrap();
+
+    // Get saga and update state
+    let mut saga = db.get_saga(&saga_id).await.unwrap().unwrap();
+    assert_eq!(saga.version, 0);
+
+    saga.update_state(WalletSagaState::Swap(SwapSagaState::SwapRequested));
+    assert_eq!(saga.version, 1);
+
+    // Update should succeed
+    let success = db.update_saga(saga.clone()).await.unwrap();
+    assert!(success);
+
+    // Verify updated state
+    let retrieved = db.get_saga(&saga_id).await.unwrap().unwrap();
+    assert_eq!(retrieved.version, 1);
+    assert!(matches!(
+        retrieved.state,
+        WalletSagaState::Swap(SwapSagaState::SwapRequested)
+    ));
+
+    // Try to update with stale version (simulating concurrent access)
+    let mut stale_saga = saga.clone();
+    stale_saga.version = 0; // Reset to old version
+    stale_saga.update_state(WalletSagaState::Swap(SwapSagaState::ProofsReserved));
+    // Now version is 1, but DB has version 1, so expected_version would be 0
+
+    // This should fail due to version mismatch
+    let success = db.update_saga(stale_saga).await.unwrap();
+    assert!(!success);
+
+    // Original state should be unchanged
+    let retrieved = db.get_saga(&saga_id).await.unwrap().unwrap();
+    assert_eq!(retrieved.version, 1);
+    assert!(matches!(
+        retrieved.state,
+        WalletSagaState::Swap(SwapSagaState::SwapRequested)
+    ));
+}
+
+/// Test deleting a saga
+pub async fn delete_saga<DB>(db: DB)
+where
+    DB: Database<crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let saga = test_wallet_saga(mint_url);
+    let saga_id = saga.id;
+
+    // Add saga
+    db.add_saga(saga).await.unwrap();
+
+    // Verify it exists
+    let retrieved = db.get_saga(&saga_id).await.unwrap();
+    assert!(retrieved.is_some());
+
+    // Delete saga
+    db.delete_saga(&saga_id).await.unwrap();
+
+    // Verify it's gone
+    let retrieved = db.get_saga(&saga_id).await.unwrap();
+    assert!(retrieved.is_none());
+}
+
+/// Test getting incomplete sagas
+pub async fn get_incomplete_sagas<DB>(db: DB)
+where
+    DB: Database<crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+
+    // Add multiple sagas
+    let saga1 = test_wallet_saga(mint_url.clone());
+    let saga2 = test_wallet_saga(mint_url.clone());
+    let saga3 = test_wallet_saga(mint_url);
+    let saga1_id = saga1.id;
+    let saga3_id = saga3.id;
+
+    db.add_saga(saga1).await.unwrap();
+    db.add_saga(saga2.clone()).await.unwrap();
+    db.add_saga(saga3).await.unwrap();
+
+    // Get all incomplete sagas
+    let incomplete = db.get_incomplete_sagas().await.unwrap();
+    assert_eq!(incomplete.len(), 3);
+
+    // Delete one saga (simulating completion)
+    db.delete_saga(&saga2.id).await.unwrap();
+
+    // Should now have 2 incomplete
+    let incomplete = db.get_incomplete_sagas().await.unwrap();
+    assert_eq!(incomplete.len(), 2);
+
+    // Verify the correct ones remain
+    let ids: Vec<_> = incomplete.iter().map(|s| s.id).collect();
+    assert!(ids.contains(&saga1_id));
+    assert!(ids.contains(&saga3_id));
+}
+
+// =============================================================================
+// Proof Reservation Tests
+// =============================================================================
+
+/// Test reserving proofs for an operation
+pub async fn reserve_proofs<DB>(db: DB)
+where
+    DB: Database<crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let keyset_id = test_keyset_id();
+    let proof_info_1 = test_proof_info(keyset_id, 100, mint_url.clone());
+    let proof_info_2 = test_proof_info(keyset_id, 200, mint_url.clone());
+
+    // Add proofs
+    db.update_proofs(vec![proof_info_1.clone(), proof_info_2.clone()], vec![])
+        .await
+        .unwrap();
+
+    // Reserve proofs for an operation
+    let operation_id = uuid::Uuid::new_v4();
+    db.reserve_proofs(vec![proof_info_1.y, proof_info_2.y], &operation_id)
+        .await
+        .unwrap();
+
+    // Verify proofs are now reserved
+    let proofs = db
+        .get_proofs(None, None, Some(vec![State::Reserved]), None)
+        .await
+        .unwrap();
+    assert_eq!(proofs.len(), 2);
+}
+
+/// Test releasing reserved proofs
+pub async fn release_proofs<DB>(db: DB)
+where
+    DB: Database<crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let keyset_id = test_keyset_id();
+    let proof_info_1 = test_proof_info(keyset_id, 100, mint_url.clone());
+    let proof_info_2 = test_proof_info(keyset_id, 200, mint_url.clone());
+
+    // Add proofs
+    db.update_proofs(vec![proof_info_1.clone(), proof_info_2.clone()], vec![])
+        .await
+        .unwrap();
+
+    // Reserve proofs
+    let operation_id = uuid::Uuid::new_v4();
+    db.reserve_proofs(vec![proof_info_1.y, proof_info_2.y], &operation_id)
+        .await
+        .unwrap();
+
+    // Verify they're reserved
+    let reserved = db
+        .get_proofs(None, None, Some(vec![State::Reserved]), None)
+        .await
+        .unwrap();
+    assert_eq!(reserved.len(), 2);
+
+    // Release proofs
+    db.release_proofs(&operation_id).await.unwrap();
+
+    // Verify proofs are back to unspent
+    let unspent = db
+        .get_proofs(None, None, Some(vec![State::Unspent]), None)
+        .await
+        .unwrap();
+    assert_eq!(unspent.len(), 2);
+
+    let reserved = db
+        .get_proofs(None, None, Some(vec![State::Reserved]), None)
+        .await
+        .unwrap();
+    assert!(reserved.is_empty());
+}
+
+/// Test getting proofs reserved by an operation
+pub async fn get_reserved_proofs<DB>(db: DB)
+where
+    DB: Database<crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let keyset_id = test_keyset_id();
+    let proof_info_1 = test_proof_info(keyset_id, 100, mint_url.clone());
+    let proof_info_2 = test_proof_info(keyset_id, 200, mint_url.clone());
+    let proof_info_3 = test_proof_info(keyset_id, 300, mint_url.clone());
+
+    // Add proofs
+    db.update_proofs(
+        vec![
+            proof_info_1.clone(),
+            proof_info_2.clone(),
+            proof_info_3.clone(),
+        ],
+        vec![],
+    )
+    .await
+    .unwrap();
+
+    // Reserve some proofs for operation 1
+    let operation_id_1 = uuid::Uuid::new_v4();
+    db.reserve_proofs(vec![proof_info_1.y, proof_info_2.y], &operation_id_1)
+        .await
+        .unwrap();
+
+    // Reserve other proofs for operation 2
+    let operation_id_2 = uuid::Uuid::new_v4();
+    db.reserve_proofs(vec![proof_info_3.y], &operation_id_2)
+        .await
+        .unwrap();
+
+    // Get proofs for operation 1
+    let reserved_1 = db.get_reserved_proofs(&operation_id_1).await.unwrap();
+    assert_eq!(reserved_1.len(), 2);
+    let ys_1: Vec<_> = reserved_1.iter().map(|p| p.y).collect();
+    assert!(ys_1.contains(&proof_info_1.y));
+    assert!(ys_1.contains(&proof_info_2.y));
+
+    // Get proofs for operation 2
+    let reserved_2 = db.get_reserved_proofs(&operation_id_2).await.unwrap();
+    assert_eq!(reserved_2.len(), 1);
+    assert_eq!(reserved_2[0].y, proof_info_3.y);
+
+    // Get proofs for non-existent operation
+    let empty = db.get_reserved_proofs(&uuid::Uuid::new_v4()).await.unwrap();
+    assert!(empty.is_empty());
+}
+
+/// Test that reserving already reserved proofs fails
+pub async fn reserve_proofs_already_reserved<DB>(db: DB)
+where
+    DB: Database<crate::database::Error>,
+{
+    let mint_url = test_mint_url();
+    let keyset_id = test_keyset_id();
+    let proof_info = test_proof_info(keyset_id, 100, mint_url.clone());
+
+    // Add proof
+    db.update_proofs(vec![proof_info.clone()], vec![])
+        .await
+        .unwrap();
+
+    // Reserve proof
+    let operation_id_1 = uuid::Uuid::new_v4();
+    db.reserve_proofs(vec![proof_info.y], &operation_id_1)
+        .await
+        .unwrap();
+
+    // Try to reserve the same proof for another operation - should fail
+    let operation_id_2 = uuid::Uuid::new_v4();
+    let result = db.reserve_proofs(vec![proof_info.y], &operation_id_2).await;
+    assert!(result.is_err());
+}
+
 /// Unit test that is expected to be passed for a correct wallet database implementation
 #[macro_export]
 macro_rules! wallet_db_test {
@@ -1041,6 +1434,8 @@ macro_rules! wallet_db_test {
             add_and_get_melt_quote,
             get_melt_quote_in_transaction,
             remove_melt_quote,
+            add_mint_quote_optimistic_locking,
+            add_melt_quote_optimistic_locking,
             add_and_get_proofs,
             get_proofs_in_transaction,
             update_proofs,
@@ -1059,7 +1454,15 @@ macro_rules! wallet_db_test {
             kvstore_list,
             kvstore_update,
             kvstore_remove,
-            kvstore_namespace_isolation
+            kvstore_namespace_isolation,
+            add_and_get_saga,
+            update_saga_optimistic_locking,
+            delete_saga,
+            get_incomplete_sagas,
+            reserve_proofs,
+            release_proofs,
+            get_reserved_proofs,
+            reserve_proofs_already_reserved
         );
     };
     ($make_db_fn:ident, $($name:ident),+ $(,)?) => {

+ 216 - 3
crates/cdk-common/src/error.rs

@@ -160,7 +160,7 @@ pub enum Error {
     /// Amount is outside of allowed range
     #[error("Amount must be between `{0}` and `{1}` is `{2}`")]
     AmountOutofLimitRange(Amount, Amount, Amount),
-    /// Quote is not paiud
+    /// Quote is not paid
     #[error("Quote not paid")]
     UnpaidQuote,
     /// Quote is pending
@@ -176,7 +176,7 @@ pub enum Error {
     #[error("Payment state is unknown")]
     UnknownPaymentState,
     /// Melting is disabled
-    #[error("Minting is disabled")]
+    #[error("Melting is disabled")]
     MeltingDisabled,
     /// Unknown Keyset
     #[error("Unknown Keyset")]
@@ -313,9 +313,21 @@ pub enum Error {
     /// Transaction not found
     #[error("Transaction not found")]
     TransactionNotFound,
+    /// Invalid operation kind
+    #[error("Invalid operation kind")]
+    InvalidOperationKind,
+    /// Invalid operation state
+    #[error("Invalid operation state")]
+    InvalidOperationState,
+    /// Operation not found
+    #[error("Operation not found")]
+    OperationNotFound,
     /// KV Store invalid key or namespace
     #[error("Invalid KV store key or namespace: {0}")]
     KVStoreInvalidKey(String),
+    /// Concurrent update detected
+    #[error("Concurrent update detected")]
+    ConcurrentUpdate,
     /// Invalid response from mint
     #[error("Invalid mint response: {0}")]
     InvalidMintResponse(String),
@@ -435,6 +447,195 @@ pub enum Error {
     Payment(#[from] crate::payment::Error),
 }
 
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_is_definitive_failure() {
+        // Test definitive failures
+        assert!(Error::AmountOverflow.is_definitive_failure());
+        assert!(Error::TokenAlreadySpent.is_definitive_failure());
+        assert!(Error::MintingDisabled.is_definitive_failure());
+
+        // Test HTTP client errors (4xx) - simulated
+        assert!(Error::HttpError(Some(400), "Bad Request".to_string()).is_definitive_failure());
+        assert!(Error::HttpError(Some(404), "Not Found".to_string()).is_definitive_failure());
+        assert!(
+            Error::HttpError(Some(429), "Too Many Requests".to_string()).is_definitive_failure()
+        );
+
+        // Test ambiguous failures
+        assert!(!Error::Timeout.is_definitive_failure());
+        assert!(!Error::Internal.is_definitive_failure());
+        assert!(!Error::ConcurrentUpdate.is_definitive_failure());
+
+        // Test HTTP server errors (5xx)
+        assert!(
+            !Error::HttpError(Some(500), "Internal Server Error".to_string())
+                .is_definitive_failure()
+        );
+        assert!(!Error::HttpError(Some(502), "Bad Gateway".to_string()).is_definitive_failure());
+        assert!(
+            !Error::HttpError(Some(503), "Service Unavailable".to_string()).is_definitive_failure()
+        );
+
+        // Test HTTP network errors (no status)
+        assert!(!Error::HttpError(None, "Connection refused".to_string()).is_definitive_failure());
+    }
+}
+
+impl Error {
+    /// Check if the error is a definitive failure
+    ///
+    /// A definitive failure means the mint definitely rejected the request
+    /// and did not update its state. In these cases, it is safe to revert
+    /// the transaction locally.
+    ///
+    /// If false, the failure is ambiguous (e.g. timeout, network error, 500)
+    /// and the transaction state at the mint is unknown.
+    pub fn is_definitive_failure(&self) -> bool {
+        match self {
+            // Logic/Validation Errors (Safe to revert)
+            Self::AmountKey
+            | Self::KeysetUnknown(_)
+            | Self::UnsupportedUnit
+            | Self::InvoiceAmountUndefined
+            | Self::SplitValuesGreater
+            | Self::AmountOverflow
+            | Self::OverIssue
+            | Self::SignatureMissingOrInvalid
+            | Self::AmountLessNotAllowed
+            | Self::InternalMultiPartMeltQuote
+            | Self::MppUnitMethodNotSupported(_, _)
+            | Self::AmountlessInvoiceNotSupported(_, _)
+            | Self::DuplicatePaymentId
+            | Self::PubkeyRequired
+            | Self::InvalidPaymentMethod
+            | Self::UnsupportedPaymentMethod
+            | Self::InvalidInvoice
+            | Self::MintingDisabled
+            | Self::UnknownQuote
+            | Self::ExpiredQuote(_, _)
+            | Self::AmountOutofLimitRange(_, _, _)
+            | Self::UnpaidQuote
+            | Self::PendingQuote
+            | Self::IssuedQuote
+            | Self::PaidQuote
+            | Self::MeltingDisabled
+            | Self::UnknownKeySet
+            | Self::BlindedMessageAlreadySigned
+            | Self::InactiveKeyset
+            | Self::TransactionUnbalanced(_, _, _)
+            | Self::DuplicateInputs
+            | Self::DuplicateOutputs
+            | Self::MultipleUnits
+            | Self::UnitMismatch
+            | Self::SigAllUsedInMelt
+            | Self::TokenAlreadySpent
+            | Self::TokenPending
+            | Self::P2PKConditionsNotMet(_)
+            | Self::DuplicateSignatureError
+            | Self::LocktimeNotProvided
+            | Self::InvalidSpendConditions(_)
+            | Self::IncorrectWallet(_)
+            | Self::MaxFeeExceeded
+            | Self::DleqProofNotProvided
+            | Self::IncorrectMint
+            | Self::MultiMintTokenNotSupported
+            | Self::PreimageNotProvided
+            | Self::MultiMintCurrencyUnitMismatch { .. }
+            | Self::UnknownMint { .. }
+            | Self::UnexpectedProofState
+            | Self::NoActiveKeyset
+            | Self::IncorrectQuoteAmount
+            | Self::InvoiceDescriptionUnsupported
+            | Self::InvalidTransactionDirection
+            | Self::InvalidTransactionId
+            | Self::InvalidOperationKind
+            | Self::InvalidOperationState
+            | Self::OperationNotFound
+            | Self::KVStoreInvalidKey(_) => true,
+
+            // HTTP Errors
+            Self::HttpError(Some(status), _) => {
+                // Client errors (400-499) are definitive failures
+                // Server errors (500-599) are ambiguous
+                (400..500).contains(status)
+            }
+
+            // Ambiguous Errors (Unsafe to revert)
+            Self::Timeout
+            | Self::Internal
+            | Self::UnknownPaymentState
+            | Self::CouldNotGetMintInfo
+            | Self::UnknownErrorResponse(_)
+            | Self::InvalidMintResponse(_)
+            | Self::ConcurrentUpdate
+            | Self::SendError(_)
+            | Self::RecvError(_)
+            | Self::TransferTimeout { .. } => false,
+
+            // Network/IO/Parsing Errors (Usually ambiguous as they could happen reading response)
+            Self::HttpError(None, _) // No status code means network error
+            | Self::SerdeJsonError(_) // Could be malformed success response
+            | Self::Database(_)
+            | Self::Custom(_) => false,
+
+            // Auth Errors (Generally definitive if rejected)
+            Self::ClearAuthRequired
+            | Self::BlindAuthRequired
+            | Self::ClearAuthFailed
+            | Self::BlindAuthFailed
+            | Self::InsufficientBlindAuthTokens
+            | Self::AuthSettingsUndefined
+            | Self::AuthLocalstoreUndefined
+            | Self::OidcNotSet => true,
+
+            // External conversions - check specifically
+            Self::Invoice(_) => true, // Parsing error
+            Self::Bip32(_) => true, // Key derivation error
+            Self::ParseInt(_) => true,
+            Self::UrlParseError(_) => true,
+            Self::Utf8ParseError(_) => true,
+            Self::Base64Error(_) => true,
+            Self::HexError(_) => true,
+            #[cfg(feature = "mint")]
+            Self::Uuid(_) => true,
+            Self::CashuUrl(_) => true,
+            Self::Secret(_) => true,
+            Self::AmountError(_) => true,
+            Self::DHKE(_) => true, // Crypto errors
+            Self::NUT00(_) => true,
+            Self::NUT01(_) => true,
+            Self::NUT02(_) => true,
+            Self::NUT03(_) => true,
+            Self::NUT04(_) => true,
+            Self::NUT05(_) => true,
+            Self::NUT11(_) => true,
+            Self::NUT12(_) => true,
+            #[cfg(feature = "wallet")]
+            Self::NUT13(_) => true,
+            Self::NUT14(_) => true,
+            Self::NUT18(_) => true,
+            Self::NUT20(_) => true,
+            #[cfg(feature = "auth")]
+            Self::NUT21(_) => true,
+            #[cfg(feature = "auth")]
+            Self::NUT22(_) => true,
+            Self::NUT23(_) => true,
+            #[cfg(feature = "mint")]
+            Self::QuoteId(_) => true,
+            Self::TryFromSliceError(_) => true,
+            #[cfg(feature = "mint")]
+            Self::Payment(_) => false, // Payment errors could be ambiguous? assume ambiguous to be safe
+
+            // Catch-all
+            _ => false,
+        }
+    }
+}
+
 /// CDK Error Response
 ///
 /// See NUT definition in [00](https://github.com/cashubtc/nuts/blob/main/00.md)
@@ -727,6 +928,10 @@ impl From<Error> for ErrorResponse {
                 code: ErrorCode::Unknown(50000),
                 detail: err.to_string(),
             },
+            Error::ConcurrentUpdate => ErrorResponse {
+                code: ErrorCode::ConcurrentUpdate,
+                detail: err.to_string(),
+            },
 
             // Fallback for any remaining errors - use Unknown(99999) instead of TokenNotVerified
             _ => ErrorResponse {
@@ -747,6 +952,7 @@ impl From<crate::database::Error> for Error {
                 crate::state::Error::AlreadyPaid => Self::RequestAlreadyPaid,
                 state => Self::Database(crate::database::Error::InvalidStateTransition(state)),
             },
+            crate::database::Error::ConcurrentUpdate => Self::ConcurrentUpdate,
             db_error => Self::Database(db_error),
         }
     }
@@ -755,7 +961,10 @@ impl From<crate::database::Error> for Error {
 #[cfg(not(feature = "mint"))]
 impl From<crate::database::Error> for Error {
     fn from(db_error: crate::database::Error) -> Self {
-        Self::Database(db_error)
+        match db_error {
+            crate::database::Error::ConcurrentUpdate => Self::ConcurrentUpdate,
+            db_error => Self::Database(db_error),
+        }
     }
 }
 
@@ -884,6 +1093,9 @@ pub enum ErrorCode {
     /// BAT mint rate limit exceeded (31004)
     BatRateLimitExceeded,
 
+    /// Concurrent update detected
+    ConcurrentUpdate,
+
     /// Unknown error code
     Unknown(u16),
 }
@@ -973,6 +1185,7 @@ impl ErrorCode {
             Self::BlindAuthFailed => 31002,
             Self::BatMintMaxExceeded => 31003,
             Self::BatRateLimitExceeded => 31004,
+            Self::ConcurrentUpdate => 50000,
             Self::Unknown(code) => *code,
         }
     }

+ 2 - 0
crates/cdk-common/src/lib.rs

@@ -33,6 +33,8 @@ pub use cashu::nuts::{self, *};
 #[cfg(feature = "mint")]
 pub use cashu::quote_id::{self, *};
 pub use cashu::{dhke, ensure_cdk, mint_url, secret, util, SECP256K1};
+// Re-export common types
+pub use common::FinalizedMelt;
 pub use error::Error;
 /// Re-export parking_lot for reuse
 pub use parking_lot;

+ 1 - 0
crates/cdk-common/src/task.rs

@@ -1,6 +1,7 @@
 //! Thin wrapper for spawn and spawn_local for native and wasm.
 
 use std::future::Future;
+#[cfg(not(target_arch = "wasm32"))]
 use std::sync::OnceLock;
 
 use tokio::task::JoinHandle;

+ 285 - 13
crates/cdk-common/src/wallet.rs → crates/cdk-common/src/wallet/mod.rs

@@ -6,13 +6,24 @@ use std::str::FromStr;
 
 use bitcoin::hashes::{sha256, Hash, HashEngine};
 use cashu::util::hex;
-use cashu::{nut00, PaymentMethod, Proofs, PublicKey};
+use cashu::{nut00, PaymentMethod, Proof, Proofs, PublicKey};
 use serde::{Deserialize, Serialize};
+use uuid::Uuid;
 
 use crate::mint_url::MintUrl;
-use crate::nuts::{CurrencyUnit, MeltQuoteState, MintQuoteState, SecretKey};
+use crate::nuts::{
+    CurrencyUnit, MeltQuoteState, MintQuoteState, SecretKey, SpendingConditions, State,
+};
 use crate::{Amount, Error};
 
+pub mod saga;
+
+pub use saga::{
+    IssueSagaState, MeltOperationData, MeltSagaState, MintOperationData, OperationData,
+    ReceiveOperationData, ReceiveSagaState, SendOperationData, SendSagaState, SwapOperationData,
+    SwapSagaState, WalletSaga, WalletSagaState,
+};
+
 /// Wallet Key
 #[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
 pub struct WalletKey {
@@ -35,6 +46,123 @@ impl WalletKey {
     }
 }
 
+/// Proof info
+#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ProofInfo {
+    /// Proof
+    pub proof: Proof,
+    /// y
+    pub y: PublicKey,
+    /// Mint Url
+    pub mint_url: MintUrl,
+    /// Proof State
+    pub state: State,
+    /// Proof Spending Conditions
+    pub spending_condition: Option<SpendingConditions>,
+    /// Unit
+    pub unit: CurrencyUnit,
+    /// Operation ID that is using/spending this proof
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub used_by_operation: Option<Uuid>,
+    /// Operation ID that created this proof
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub created_by_operation: Option<Uuid>,
+}
+
+impl ProofInfo {
+    /// Create new [`ProofInfo`]
+    pub fn new(
+        proof: Proof,
+        mint_url: MintUrl,
+        state: State,
+        unit: CurrencyUnit,
+    ) -> Result<Self, Error> {
+        let y = proof.y()?;
+
+        let spending_condition: Option<SpendingConditions> = (&proof.secret).try_into().ok();
+
+        Ok(Self {
+            proof,
+            y,
+            mint_url,
+            state,
+            spending_condition,
+            unit,
+            used_by_operation: None,
+            created_by_operation: None,
+        })
+    }
+
+    /// Create new [`ProofInfo`] with operation tracking
+    pub fn new_with_operations(
+        proof: Proof,
+        mint_url: MintUrl,
+        state: State,
+        unit: CurrencyUnit,
+        used_by_operation: Option<Uuid>,
+        created_by_operation: Option<Uuid>,
+    ) -> Result<Self, Error> {
+        let y = proof.y()?;
+
+        let spending_condition: Option<SpendingConditions> = (&proof.secret).try_into().ok();
+
+        Ok(Self {
+            proof,
+            y,
+            mint_url,
+            state,
+            spending_condition,
+            unit,
+            used_by_operation,
+            created_by_operation,
+        })
+    }
+
+    /// Check if [`Proof`] matches conditions
+    pub fn matches_conditions(
+        &self,
+        mint_url: &Option<MintUrl>,
+        unit: &Option<CurrencyUnit>,
+        state: &Option<Vec<State>>,
+        spending_conditions: &Option<Vec<SpendingConditions>>,
+    ) -> bool {
+        if let Some(mint_url) = mint_url {
+            if mint_url.ne(&self.mint_url) {
+                return false;
+            }
+        }
+
+        if let Some(unit) = unit {
+            if unit.ne(&self.unit) {
+                return false;
+            }
+        }
+
+        if let Some(state) = state {
+            if !state.contains(&self.state) {
+                return false;
+            }
+        }
+
+        if let Some(spending_conditions) = spending_conditions {
+            match &self.spending_condition {
+                None => {
+                    if !spending_conditions.is_empty() {
+                        return false;
+                    }
+                }
+                Some(s) => {
+                    if !spending_conditions.contains(s) {
+                        return false;
+                    }
+                }
+            }
+        }
+
+        true
+    }
+}
+
 /// Mint Quote Info
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct MintQuote {
@@ -62,6 +190,12 @@ pub struct MintQuote {
     /// Amount paid to the mint for the quote
     #[serde(default)]
     pub amount_paid: Amount,
+    /// Operation ID that has reserved this quote (for saga pattern)
+    #[serde(default)]
+    pub used_by_operation: Option<String>,
+    /// Version for optimistic locking
+    #[serde(default)]
+    pub version: u32,
 }
 
 /// Melt Quote Info
@@ -85,6 +219,12 @@ pub struct MeltQuote {
     pub payment_preimage: Option<String>,
     /// Payment method
     pub payment_method: PaymentMethod,
+    /// Operation ID that has reserved this quote (for saga pattern)
+    #[serde(default)]
+    pub used_by_operation: Option<String>,
+    /// Version for optimistic locking
+    #[serde(default)]
+    pub version: u32,
 }
 
 impl MintQuote {
@@ -112,6 +252,8 @@ impl MintQuote {
             secret_key,
             amount_issued: Amount::ZERO,
             amount_paid: Amount::ZERO,
+            used_by_operation: None,
+            version: 0,
         }
     }
 
@@ -127,19 +269,17 @@ impl MintQuote {
 
     /// Amount that can be minted
     pub fn amount_mintable(&self) -> Amount {
-        if self.amount_issued > self.amount_paid {
-            return Amount::ZERO;
-        }
-
-        let difference = self.amount_paid - self.amount_issued;
-
-        if difference == Amount::ZERO && self.state != MintQuoteState::Issued {
-            if let Some(amount) = self.amount {
-                return amount;
+        if self.payment_method == PaymentMethod::BOLT11 {
+            // BOLT11 is all-or-nothing: mint full amount when state is Paid
+            if self.state == MintQuoteState::Paid {
+                self.amount.unwrap_or(Amount::ZERO)
+            } else {
+                Amount::ZERO
             }
+        } else {
+            // Other payment methods track incremental payments
+            self.amount_paid.saturating_sub(self.amount_issued)
         }
-
-        difference
     }
 }
 
@@ -209,6 +349,9 @@ pub struct Transaction {
     /// Payment method (e.g., Bolt11, Bolt12) for mint/melt transactions
     #[serde(default)]
     pub payment_method: Option<PaymentMethod>,
+    /// Saga ID if this transaction was part of a saga
+    #[serde(default)]
+    pub saga_id: Option<Uuid>,
 }
 
 impl Transaction {
@@ -374,9 +517,54 @@ impl TryFrom<Proofs> for TransactionId {
     }
 }
 
+/// Wallet operation kind
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum OperationKind {
+    /// Send operation
+    Send,
+    /// Receive operation
+    Receive,
+    /// Swap operation
+    Swap,
+    /// Mint operation
+    Mint,
+    /// Melt operation
+    Melt,
+}
+
+impl fmt::Display for OperationKind {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            OperationKind::Send => write!(f, "send"),
+            OperationKind::Receive => write!(f, "receive"),
+            OperationKind::Swap => write!(f, "swap"),
+            OperationKind::Mint => write!(f, "mint"),
+            OperationKind::Melt => write!(f, "melt"),
+        }
+    }
+}
+
+impl FromStr for OperationKind {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s {
+            "send" => Ok(OperationKind::Send),
+            "receive" => Ok(OperationKind::Receive),
+            "swap" => Ok(OperationKind::Swap),
+            "mint" => Ok(OperationKind::Mint),
+            "melt" => Ok(OperationKind::Melt),
+            _ => Err(Error::InvalidOperationKind),
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::nuts::Id;
+    use crate::secret::Secret;
 
     #[test]
     fn test_transaction_id_from_hex() {
@@ -398,4 +586,88 @@ mod tests {
         let res = TransactionId::from_hex(hex_str);
         assert!(matches!(res, Err(Error::InvalidTransactionId)));
     }
+
+    #[test]
+    fn test_matches_conditions() {
+        let keyset_id = Id::from_str("00deadbeef123456").unwrap();
+        let proof = Proof::new(
+            Amount::from(64),
+            keyset_id,
+            Secret::new("test_secret"),
+            PublicKey::from_hex(
+                "02deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
+            )
+            .unwrap(),
+        );
+
+        let mint_url = MintUrl::from_str("https://example.com").unwrap();
+        let proof_info =
+            ProofInfo::new(proof, mint_url.clone(), State::Unspent, CurrencyUnit::Sat).unwrap();
+
+        // Test matching mint_url
+        assert!(proof_info.matches_conditions(&Some(mint_url.clone()), &None, &None, &None));
+        assert!(!proof_info.matches_conditions(
+            &Some(MintUrl::from_str("https://different.com").unwrap()),
+            &None,
+            &None,
+            &None
+        ));
+
+        // Test matching unit
+        assert!(proof_info.matches_conditions(&None, &Some(CurrencyUnit::Sat), &None, &None));
+        assert!(!proof_info.matches_conditions(&None, &Some(CurrencyUnit::Msat), &None, &None));
+
+        // Test matching state
+        assert!(proof_info.matches_conditions(&None, &None, &Some(vec![State::Unspent]), &None));
+        assert!(proof_info.matches_conditions(
+            &None,
+            &None,
+            &Some(vec![State::Unspent, State::Spent]),
+            &None
+        ));
+        assert!(!proof_info.matches_conditions(&None, &None, &Some(vec![State::Spent]), &None));
+
+        // Test with no conditions (should match)
+        assert!(proof_info.matches_conditions(&None, &None, &None, &None));
+
+        // Test with multiple conditions
+        assert!(proof_info.matches_conditions(
+            &Some(mint_url),
+            &Some(CurrencyUnit::Sat),
+            &Some(vec![State::Unspent]),
+            &None
+        ));
+    }
+
+    #[test]
+    fn test_matches_conditions_with_spending_conditions() {
+        // This test would need to be expanded with actual SpendingConditions
+        // implementation, but we can test the basic case where no spending
+        // conditions are present
+
+        let keyset_id = Id::from_str("00deadbeef123456").unwrap();
+        let proof = Proof::new(
+            Amount::from(64),
+            keyset_id,
+            Secret::new("test_secret"),
+            PublicKey::from_hex(
+                "02deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
+            )
+            .unwrap(),
+        );
+
+        let mint_url = MintUrl::from_str("https://example.com").unwrap();
+        let proof_info =
+            ProofInfo::new(proof, mint_url, State::Unspent, CurrencyUnit::Sat).unwrap();
+
+        // Test with empty spending conditions (should match when proof has none)
+        assert!(proof_info.matches_conditions(&None, &None, &None, &Some(vec![])));
+
+        // Test with non-empty spending conditions (should not match when proof has none)
+        let dummy_condition = SpendingConditions::P2PKConditions {
+            data: SecretKey::generate().public_key(),
+            conditions: None,
+        };
+        assert!(!proof_info.matches_conditions(&None, &None, &None, &Some(vec![dummy_condition])));
+    }
 }

+ 55 - 0
crates/cdk-common/src/wallet/saga/issue.rs

@@ -0,0 +1,55 @@
+//! Issue (mint) saga types
+
+use cashu::BlindedMessage;
+use serde::{Deserialize, Serialize};
+
+use crate::Error;
+
+/// States specific to mint (issue) saga
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum IssueSagaState {
+    /// Pre-mint secrets created and counter incremented, ready to request signatures
+    SecretsPrepared,
+    /// Mint request sent to mint, awaiting signatures for new proofs
+    MintRequested,
+}
+
+impl std::fmt::Display for IssueSagaState {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            IssueSagaState::SecretsPrepared => write!(f, "secrets_prepared"),
+            IssueSagaState::MintRequested => write!(f, "mint_requested"),
+        }
+    }
+}
+
+impl std::str::FromStr for IssueSagaState {
+    type Err = Error;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s {
+            "secrets_prepared" => Ok(IssueSagaState::SecretsPrepared),
+            "mint_requested" => Ok(IssueSagaState::MintRequested),
+            _ => Err(Error::InvalidOperationState),
+        }
+    }
+}
+
+/// Operation-specific data for Mint operations
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct MintOperationData {
+    /// Quote ID
+    pub quote_id: String,
+    /// Amount to mint
+    pub amount: crate::Amount,
+    /// Derivation counter start
+    pub counter_start: Option<u32>,
+    /// Derivation counter end
+    pub counter_end: Option<u32>,
+    /// Blinded messages for recovery
+    ///
+    /// Stored so that if a crash occurs after the mint accepts the request,
+    /// we can use these to query the mint for signatures and reconstruct proofs.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub blinded_messages: Option<Vec<BlindedMessage>>,
+}

+ 63 - 0
crates/cdk-common/src/wallet/saga/melt.rs

@@ -0,0 +1,63 @@
+//! Melt saga types
+
+use cashu::BlindedMessage;
+use serde::{Deserialize, Serialize};
+
+use crate::{Amount, Error};
+
+/// States specific to melt saga
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum MeltSagaState {
+    /// Proofs reserved and quote locked, ready to initiate payment
+    ProofsReserved,
+    /// Melt request sent to mint, Lightning payment initiated
+    MeltRequested,
+    /// Lightning payment in progress, awaiting confirmation from network
+    PaymentPending,
+}
+
+impl std::fmt::Display for MeltSagaState {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            MeltSagaState::ProofsReserved => write!(f, "proofs_reserved"),
+            MeltSagaState::MeltRequested => write!(f, "melt_requested"),
+            MeltSagaState::PaymentPending => write!(f, "payment_pending"),
+        }
+    }
+}
+
+impl std::str::FromStr for MeltSagaState {
+    type Err = Error;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s {
+            "proofs_reserved" => Ok(MeltSagaState::ProofsReserved),
+            "melt_requested" => Ok(MeltSagaState::MeltRequested),
+            "payment_pending" => Ok(MeltSagaState::PaymentPending),
+            _ => Err(Error::InvalidOperationState),
+        }
+    }
+}
+
+/// Operation-specific data for Melt operations
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct MeltOperationData {
+    /// Quote ID
+    pub quote_id: String,
+    /// Amount to melt
+    pub amount: Amount,
+    /// Fee reserve
+    pub fee_reserve: Amount,
+    /// Derivation counter start
+    pub counter_start: Option<u32>,
+    /// Derivation counter end
+    pub counter_end: Option<u32>,
+    /// Change amount (if any)
+    pub change_amount: Option<Amount>,
+    /// Blinded messages for change recovery
+    ///
+    /// Stored so that if a crash occurs after the mint accepts the melt,
+    /// we can use these to query the mint for change signatures and reconstruct proofs.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub change_blinded_messages: Option<Vec<BlindedMessage>>,
+}

+ 247 - 0
crates/cdk-common/src/wallet/saga/mod.rs

@@ -0,0 +1,247 @@
+//! Wallet saga types for crash-tolerant recovery
+//!
+//! Sagas represent in-progress wallet operations that need to survive crashes.
+//! They use **optimistic locking** via the `version` field to handle concurrent
+//! access from multiple wallet instances safely.
+//!
+//! # Optimistic Locking
+//!
+//! When multiple wallet instances share the same database (e.g., mobile app
+//! backgrounded while desktop app runs), they might both try to recover the
+//! same incomplete saga. Optimistic locking prevents conflicts:
+//!
+//! 1. Each saga has a `version` number starting at 0
+//! 2. When updating, the database checks: `WHERE id = ? AND version = ?`
+//! 3. If the version matches, the update succeeds and `version` increments
+//! 4. If the version doesn't match, another instance modified it first
+//!
+//! This is preferable to pessimistic locking (mutexes) because:
+//! - Works across process boundaries (multiple wallet instances)
+//! - No deadlock risk
+//! - No lock expiration/cleanup needed
+//! - Conflicts are rare in practice (sagas are short-lived)
+//!
+//! Instance A reads saga with version=1
+//! Instance B reads saga with version=1
+//! Instance A updates successfully, version becomes 2
+//! Instance B's update fails (version mismatch) - it knows to skip
+
+use serde::{Deserialize, Serialize};
+
+use crate::mint_url::MintUrl;
+use crate::nuts::CurrencyUnit;
+use crate::wallet::OperationKind;
+use crate::Amount;
+
+mod issue;
+mod melt;
+mod receive;
+mod send;
+mod swap;
+
+pub use issue::{IssueSagaState, MintOperationData};
+pub use melt::{MeltOperationData, MeltSagaState};
+pub use receive::{ReceiveOperationData, ReceiveSagaState};
+pub use send::{SendOperationData, SendSagaState};
+pub use swap::{SwapOperationData, SwapSagaState};
+
+/// Wallet saga state for different operation types
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "type", content = "state", rename_all = "snake_case")]
+pub enum WalletSagaState {
+    /// Send saga states
+    Send(SendSagaState),
+    /// Receive saga states
+    Receive(ReceiveSagaState),
+    /// Swap saga states
+    Swap(SwapSagaState),
+    /// Mint (issue) saga states
+    Issue(IssueSagaState),
+    /// Melt saga states
+    Melt(MeltSagaState),
+}
+
+impl WalletSagaState {
+    /// Get the operation kind
+    pub fn kind(&self) -> OperationKind {
+        match self {
+            WalletSagaState::Send(_) => OperationKind::Send,
+            WalletSagaState::Receive(_) => OperationKind::Receive,
+            WalletSagaState::Swap(_) => OperationKind::Swap,
+            WalletSagaState::Issue(_) => OperationKind::Mint,
+            WalletSagaState::Melt(_) => OperationKind::Melt,
+        }
+    }
+
+    /// Get string representation of the inner state
+    pub fn state_str(&self) -> &'static str {
+        match self {
+            WalletSagaState::Send(s) => match s {
+                SendSagaState::ProofsReserved => "proofs_reserved",
+                SendSagaState::TokenCreated => "token_created",
+                SendSagaState::RollingBack => "rolling_back",
+            },
+            WalletSagaState::Receive(s) => match s {
+                ReceiveSagaState::ProofsPending => "proofs_pending",
+                ReceiveSagaState::SwapRequested => "swap_requested",
+            },
+            WalletSagaState::Swap(s) => match s {
+                SwapSagaState::ProofsReserved => "proofs_reserved",
+                SwapSagaState::SwapRequested => "swap_requested",
+            },
+            WalletSagaState::Issue(s) => match s {
+                IssueSagaState::SecretsPrepared => "secrets_prepared",
+                IssueSagaState::MintRequested => "mint_requested",
+            },
+            WalletSagaState::Melt(s) => match s {
+                MeltSagaState::ProofsReserved => "proofs_reserved",
+                MeltSagaState::MeltRequested => "melt_requested",
+                MeltSagaState::PaymentPending => "payment_pending",
+            },
+        }
+    }
+}
+
+/// Operation data enum
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
+pub enum OperationData {
+    /// Send operation data
+    Send(SendOperationData),
+    /// Receive operation data
+    Receive(ReceiveOperationData),
+    /// Swap operation data
+    Swap(SwapOperationData),
+    /// Mint operation data
+    Mint(MintOperationData),
+    /// Melt operation data
+    Melt(MeltOperationData),
+}
+
+impl OperationData {
+    /// Get the operation kind
+    pub fn kind(&self) -> OperationKind {
+        match self {
+            OperationData::Send(_) => OperationKind::Send,
+            OperationData::Receive(_) => OperationKind::Receive,
+            OperationData::Swap(_) => OperationKind::Swap,
+            OperationData::Mint(_) => OperationKind::Mint,
+            OperationData::Melt(_) => OperationKind::Melt,
+        }
+    }
+}
+
+/// Wallet saga for crash-tolerant recovery.
+///
+/// Sagas represent in-progress wallet operations that need to survive crashes.
+/// They use **optimistic locking** via the `version` field to handle concurrent
+/// access from multiple wallet instances safely.
+///
+/// # Optimistic Locking
+///
+/// When multiple wallet instances share the same database (e.g., mobile app
+/// backgrounded while desktop app runs), they might both try to recover the
+/// same incomplete saga. Optimistic locking prevents conflicts:
+///
+/// 1. Each saga has a `version` number starting at 0
+/// 2. When updating, the database checks: `WHERE id = ? AND version = ?`
+/// 3. If the version matches, the update succeeds and `version` increments
+/// 4. If the version doesn't match, another instance modified it first
+///
+/// This is preferable to pessimistic locking (mutexes) because:
+/// - Works across process boundaries (multiple wallet instances)
+/// - No deadlock risk
+/// - No lock expiration/cleanup needed
+/// - Conflicts are rare in practice (sagas are short-lived)
+///
+/// Instance A reads saga with version=1
+/// Instance B reads saga with version=1
+/// Instance A updates successfully, version becomes 2
+/// Instance B's update fails (version mismatch) - it knows to skip
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct WalletSaga {
+    /// Unique operation ID
+    pub id: uuid::Uuid,
+    /// Operation kind (derived from state)
+    pub kind: OperationKind,
+    /// Saga state (operation-specific)
+    pub state: WalletSagaState,
+    /// Amount involved in the operation
+    pub amount: Amount,
+    /// Mint URL
+    pub mint_url: MintUrl,
+    /// Currency unit
+    pub unit: CurrencyUnit,
+    /// Quote ID (for mint/melt operations)
+    pub quote_id: Option<String>,
+    /// Creation timestamp (unix seconds)
+    pub created_at: u64,
+    /// Last update timestamp (unix seconds)
+    pub updated_at: u64,
+    /// Operation-specific data
+    pub data: OperationData,
+    /// Version number for optimistic locking.
+    ///
+    /// Incremented on each update. Used to detect concurrent modifications:
+    /// - If update succeeds: this instance "won" the race
+    /// - If update fails (version mismatch): another instance modified it
+    ///
+    /// Recovery code should treat version conflicts as "someone else handled it"
+    /// and skip to the next saga rather than retrying.
+    pub version: u32,
+}
+
+impl WalletSaga {
+    /// Create a new wallet saga.
+    ///
+    /// The saga is created with `version = 0`. Each successful update
+    /// will increment the version for optimistic locking.
+    pub fn new(
+        id: uuid::Uuid,
+        state: WalletSagaState,
+        amount: Amount,
+        mint_url: MintUrl,
+        unit: CurrencyUnit,
+        data: OperationData,
+    ) -> Self {
+        let now = std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .unwrap_or_default()
+            .as_secs();
+
+        let quote_id = match &data {
+            OperationData::Mint(d) => Some(d.quote_id.clone()),
+            OperationData::Melt(d) => Some(d.quote_id.clone()),
+            _ => None,
+        };
+
+        Self {
+            id,
+            kind: state.kind(),
+            state,
+            amount,
+            mint_url,
+            unit,
+            quote_id,
+            created_at: now,
+            updated_at: now,
+            data,
+            version: 0,
+        }
+    }
+
+    /// Update the saga state and increment the version.
+    ///
+    /// This prepares the saga for an optimistic locking update.
+    /// The database layer will verify the previous version matches
+    /// before applying the update.
+    pub fn update_state(&mut self, state: WalletSagaState) {
+        self.state = state;
+        self.kind = state.kind();
+        self.updated_at = std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .unwrap_or_default()
+            .as_secs();
+        self.version += 1;
+    }
+}

+ 55 - 0
crates/cdk-common/src/wallet/saga/receive.rs

@@ -0,0 +1,55 @@
+//! Receive saga types
+
+use cashu::BlindedMessage;
+use serde::{Deserialize, Serialize};
+
+use crate::{Amount, Error};
+
+/// States specific to receive saga
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ReceiveSagaState {
+    /// Input proofs validated and stored as pending, ready to swap for new proofs
+    ProofsPending,
+    /// Swap request sent to mint, awaiting signatures for new proofs
+    SwapRequested,
+}
+
+impl std::fmt::Display for ReceiveSagaState {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            ReceiveSagaState::ProofsPending => write!(f, "proofs_pending"),
+            ReceiveSagaState::SwapRequested => write!(f, "swap_requested"),
+        }
+    }
+}
+
+impl std::str::FromStr for ReceiveSagaState {
+    type Err = Error;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s {
+            "proofs_pending" => Ok(ReceiveSagaState::ProofsPending),
+            "swap_requested" => Ok(ReceiveSagaState::SwapRequested),
+            _ => Err(Error::InvalidOperationState),
+        }
+    }
+}
+
+/// Operation-specific data for Receive operations
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ReceiveOperationData {
+    /// Token to receive
+    pub token: Option<String>,
+    /// Derivation counter start
+    pub counter_start: Option<u32>,
+    /// Derivation counter end
+    pub counter_end: Option<u32>,
+    /// Amount received
+    pub amount: Option<Amount>,
+    /// Blinded messages for recovery
+    ///
+    /// Stored so that if a crash occurs after the mint accepts the swap,
+    /// we can use these to query the mint for signatures and reconstruct proofs.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub blinded_messages: Option<Vec<BlindedMessage>>,
+}

+ 57 - 0
crates/cdk-common/src/wallet/saga/send.rs

@@ -0,0 +1,57 @@
+//! Send saga types
+
+use serde::{Deserialize, Serialize};
+
+use crate::nuts::Proofs;
+use crate::{Amount, Error};
+
+/// States specific to send saga
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum SendSagaState {
+    /// Proofs selected and reserved for sending, ready to create token
+    ProofsReserved,
+    /// Token created and ready to share, proofs marked as pending spent awaiting claim
+    TokenCreated,
+    /// Rollback in progress, reclaiming proofs via swap (transient state)
+    RollingBack,
+}
+
+impl std::fmt::Display for SendSagaState {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            SendSagaState::ProofsReserved => write!(f, "proofs_reserved"),
+            SendSagaState::TokenCreated => write!(f, "token_created"),
+            SendSagaState::RollingBack => write!(f, "rolling_back"),
+        }
+    }
+}
+
+impl std::str::FromStr for SendSagaState {
+    type Err = Error;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s {
+            "proofs_reserved" => Ok(SendSagaState::ProofsReserved),
+            "token_created" => Ok(SendSagaState::TokenCreated),
+            "rolling_back" => Ok(SendSagaState::RollingBack),
+            _ => Err(Error::InvalidOperationState),
+        }
+    }
+}
+
+/// Operation-specific data for Send operations
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SendOperationData {
+    /// Target amount to send
+    pub amount: Amount,
+    /// Memo for the send
+    pub memo: Option<String>,
+    /// Derivation counter start
+    pub counter_start: Option<u32>,
+    /// Derivation counter end
+    pub counter_end: Option<u32>,
+    /// Token data (when in Pending/Finalized state)
+    pub token: Option<String>,
+    /// Proofs being sent
+    pub proofs: Option<Proofs>,
+}

+ 55 - 0
crates/cdk-common/src/wallet/saga/swap.rs

@@ -0,0 +1,55 @@
+//! Swap saga types
+
+use cashu::BlindedMessage;
+use serde::{Deserialize, Serialize};
+
+use crate::{Amount, Error};
+
+/// States specific to swap saga (wallet-side)
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum SwapSagaState {
+    /// Input proofs reserved, swap request prepared, ready to execute
+    ProofsReserved,
+    /// Swap request sent to mint, awaiting signatures for new proofs
+    SwapRequested,
+}
+
+impl std::fmt::Display for SwapSagaState {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            SwapSagaState::ProofsReserved => write!(f, "proofs_reserved"),
+            SwapSagaState::SwapRequested => write!(f, "swap_requested"),
+        }
+    }
+}
+
+impl std::str::FromStr for SwapSagaState {
+    type Err = Error;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s {
+            "proofs_reserved" => Ok(SwapSagaState::ProofsReserved),
+            "swap_requested" => Ok(SwapSagaState::SwapRequested),
+            _ => Err(Error::InvalidOperationState),
+        }
+    }
+}
+
+/// Operation-specific data for Swap operations
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SwapOperationData {
+    /// Input amount
+    pub input_amount: Amount,
+    /// Output amount
+    pub output_amount: Amount,
+    /// Derivation counter start
+    pub counter_start: Option<u32>,
+    /// Derivation counter end
+    pub counter_end: Option<u32>,
+    /// Blinded messages for recovery
+    ///
+    /// Stored so that if a crash occurs after the mint accepts the swap,
+    /// we can use these to query the mint for signatures and reconstruct proofs.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub blinded_messages: Option<Vec<BlindedMessage>>,
+}

+ 490 - 35
crates/cdk-ffi/src/database.rs

@@ -4,6 +4,7 @@ use std::collections::HashMap;
 use std::sync::Arc;
 
 use cdk_common::database::WalletDatabase as CdkWalletDatabase;
+use cdk_common::wallet::WalletSaga;
 use cdk_sql_common::pool::DatabasePool;
 use cdk_sql_common::SQLWalletDatabase;
 
@@ -186,6 +187,64 @@ pub trait WalletDatabase: Send + Sync {
 
     /// Remove Keys from storage
     async fn remove_keys(&self, id: Id) -> Result<(), FfiError>;
+
+    // ========== Saga management methods ==========
+    // WalletSaga is serialized as JSON for FFI compatibility
+
+    /// Add a wallet saga to storage (JSON serialized)
+    async fn add_saga(&self, saga_json: String) -> Result<(), FfiError>;
+
+    /// Get a wallet saga by ID (returns JSON serialized)
+    async fn get_saga(&self, id: String) -> Result<Option<String>, FfiError>;
+
+    /// Update a wallet saga (JSON serialized) with optimistic locking.
+    ///
+    /// Returns `true` if the update succeeded (version matched),
+    /// `false` if another instance modified the saga first.
+    async fn update_saga(&self, saga_json: String) -> Result<bool, FfiError>;
+
+    /// Delete a wallet saga
+    async fn delete_saga(&self, id: String) -> Result<(), FfiError>;
+
+    /// Get all incomplete sagas (returns JSON serialized sagas)
+    async fn get_incomplete_sagas(&self) -> Result<Vec<String>, FfiError>;
+
+    // ========== Proof reservation methods ==========
+
+    /// Reserve proofs for an operation
+    async fn reserve_proofs(
+        &self,
+        ys: Vec<PublicKey>,
+        operation_id: String,
+    ) -> Result<(), FfiError>;
+
+    /// Release proofs reserved by an operation
+    async fn release_proofs(&self, operation_id: String) -> Result<(), FfiError>;
+
+    /// Get proofs reserved by an operation
+    async fn get_reserved_proofs(&self, operation_id: String) -> Result<Vec<ProofInfo>, FfiError>;
+
+    // ========== Quote reservation methods ==========
+
+    /// Reserve a melt quote for an operation
+    async fn reserve_melt_quote(
+        &self,
+        quote_id: String,
+        operation_id: String,
+    ) -> Result<(), FfiError>;
+
+    /// Release a melt quote reserved by an operation
+    async fn release_melt_quote(&self, operation_id: String) -> Result<(), FfiError>;
+
+    /// Reserve a mint quote for an operation
+    async fn reserve_mint_quote(
+        &self,
+        quote_id: String,
+        operation_id: String,
+    ) -> Result<(), FfiError>;
+
+    /// Release a mint quote reserved by an operation
+    async fn release_mint_quote(&self, operation_id: String) -> Result<(), FfiError>;
 }
 
 /// Internal bridge trait to convert from the FFI trait to the CDK database trait
@@ -453,6 +512,16 @@ impl CdkWalletDatabase<cdk::cdk_database::Error> for WalletDatabaseBridge {
                             cdk::cdk_database::Error::Database(e.to_string().into())
                         })?,
                     unit: info.unit.into(),
+                    used_by_operation: info
+                        .used_by_operation
+                        .map(|id| uuid::Uuid::parse_str(&id))
+                        .transpose()
+                        .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?,
+                    created_by_operation: info
+                        .created_by_operation
+                        .map(|id| uuid::Uuid::parse_str(&id))
+                        .transpose()
+                        .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?,
                 })
             })
             .collect();
@@ -495,6 +564,16 @@ impl CdkWalletDatabase<cdk::cdk_database::Error> for WalletDatabaseBridge {
                             cdk::cdk_database::Error::Database(e.to_string().into())
                         })?,
                     unit: info.unit.into(),
+                    used_by_operation: info
+                        .used_by_operation
+                        .map(|id| uuid::Uuid::parse_str(&id))
+                        .transpose()
+                        .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?,
+                    created_by_operation: info
+                        .created_by_operation
+                        .map(|id| uuid::Uuid::parse_str(&id))
+                        .transpose()
+                        .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?,
                 })
             })
             .collect();
@@ -723,7 +802,177 @@ impl CdkWalletDatabase<cdk::cdk_database::Error> for WalletDatabaseBridge {
             .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
     }
 
-    // KV Store write methods
+    async fn add_saga(&self, saga: WalletSaga) -> Result<(), cdk::cdk_database::Error> {
+        let json = serde_json::to_string(&saga)
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+        self.ffi_db
+            .add_saga(json)
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
+    }
+
+    async fn get_saga(
+        &self,
+        id: &uuid::Uuid,
+    ) -> Result<Option<WalletSaga>, cdk::cdk_database::Error> {
+        let json_opt = self
+            .ffi_db
+            .get_saga(id.to_string())
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+
+        match json_opt {
+            Some(json) => {
+                let saga: WalletSaga = serde_json::from_str(&json)
+                    .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+                Ok(Some(saga))
+            }
+            None => Ok(None),
+        }
+    }
+
+    async fn update_saga(&self, saga: WalletSaga) -> Result<bool, cdk::cdk_database::Error> {
+        let json = serde_json::to_string(&saga)
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+        self.ffi_db
+            .update_saga(json)
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
+    }
+
+    async fn delete_saga(&self, id: &uuid::Uuid) -> Result<(), cdk::cdk_database::Error> {
+        self.ffi_db
+            .delete_saga(id.to_string())
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
+    }
+
+    async fn get_incomplete_sagas(&self) -> Result<Vec<WalletSaga>, cdk::cdk_database::Error> {
+        let json_vec = self
+            .ffi_db
+            .get_incomplete_sagas()
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+
+        json_vec
+            .into_iter()
+            .map(|json| {
+                serde_json::from_str(&json)
+                    .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
+            })
+            .collect()
+    }
+
+    async fn reserve_proofs(
+        &self,
+        ys: Vec<cdk::nuts::PublicKey>,
+        operation_id: &uuid::Uuid,
+    ) -> Result<(), cdk::cdk_database::Error> {
+        let ffi_ys: Vec<PublicKey> = ys.into_iter().map(Into::into).collect();
+        self.ffi_db
+            .reserve_proofs(ffi_ys, operation_id.to_string())
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
+    }
+
+    async fn release_proofs(
+        &self,
+        operation_id: &uuid::Uuid,
+    ) -> Result<(), cdk::cdk_database::Error> {
+        self.ffi_db
+            .release_proofs(operation_id.to_string())
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
+    }
+
+    async fn get_reserved_proofs(
+        &self,
+        operation_id: &uuid::Uuid,
+    ) -> Result<Vec<cdk::types::ProofInfo>, cdk::cdk_database::Error> {
+        let result = self
+            .ffi_db
+            .get_reserved_proofs(operation_id.to_string())
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+
+        result
+            .into_iter()
+            .map(|info| {
+                Ok(cdk::types::ProofInfo {
+                    proof: info.proof.try_into().map_err(|e: FfiError| {
+                        cdk::cdk_database::Error::Database(e.to_string().into())
+                    })?,
+                    y: info.y.try_into().map_err(|e: FfiError| {
+                        cdk::cdk_database::Error::Database(e.to_string().into())
+                    })?,
+                    mint_url: info.mint_url.try_into().map_err(|e: FfiError| {
+                        cdk::cdk_database::Error::Database(e.to_string().into())
+                    })?,
+                    state: info.state.into(),
+                    spending_condition: info
+                        .spending_condition
+                        .map(|sc| sc.try_into())
+                        .transpose()
+                        .map_err(|e: FfiError| {
+                            cdk::cdk_database::Error::Database(e.to_string().into())
+                        })?,
+                    unit: info.unit.into(),
+                    used_by_operation: info
+                        .used_by_operation
+                        .map(|id| uuid::Uuid::parse_str(&id))
+                        .transpose()
+                        .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?,
+                    created_by_operation: info
+                        .created_by_operation
+                        .map(|id| uuid::Uuid::parse_str(&id))
+                        .transpose()
+                        .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?,
+                })
+            })
+            .collect()
+    }
+
+    async fn reserve_melt_quote(
+        &self,
+        quote_id: &str,
+        operation_id: &uuid::Uuid,
+    ) -> Result<(), cdk::cdk_database::Error> {
+        self.ffi_db
+            .reserve_melt_quote(quote_id.to_string(), operation_id.to_string())
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
+    }
+
+    async fn release_melt_quote(
+        &self,
+        operation_id: &uuid::Uuid,
+    ) -> Result<(), cdk::cdk_database::Error> {
+        self.ffi_db
+            .release_melt_quote(operation_id.to_string())
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
+    }
+
+    async fn reserve_mint_quote(
+        &self,
+        quote_id: &str,
+        operation_id: &uuid::Uuid,
+    ) -> Result<(), cdk::cdk_database::Error> {
+        self.ffi_db
+            .reserve_mint_quote(quote_id.to_string(), operation_id.to_string())
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
+    }
+
+    async fn release_mint_quote(
+        &self,
+        operation_id: &uuid::Uuid,
+    ) -> Result<(), cdk::cdk_database::Error> {
+        self.ffi_db
+            .release_mint_quote(operation_id.to_string())
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
+    }
 
     async fn kv_write(
         &self,
@@ -795,7 +1044,7 @@ where
             .inner
             .get_proofs_by_ys(cdk_ys)
             .await
-            .map_err(FfiError::database)?;
+            .map_err(FfiError::internal)?;
 
         Ok(result.into_iter().map(Into::into).collect())
     }
@@ -806,12 +1055,12 @@ where
             .inner
             .get_mint(cdk_mint_url)
             .await
-            .map_err(FfiError::database)?;
+            .map_err(FfiError::internal)?;
         Ok(result.map(Into::into))
     }
 
     async fn get_mints(&self) -> Result<HashMap<MintUrl, Option<MintInfo>>, FfiError> {
-        let result = self.inner.get_mints().await.map_err(FfiError::database)?;
+        let result = self.inner.get_mints().await.map_err(FfiError::internal)?;
         Ok(result
             .into_iter()
             .map(|(k, v)| (k.into(), v.map(Into::into)))
@@ -827,7 +1076,7 @@ where
             .inner
             .get_mint_keysets(cdk_mint_url)
             .await
-            .map_err(FfiError::database)?;
+            .map_err(FfiError::internal)?;
         Ok(result.map(|keysets| keysets.into_iter().map(Into::into).collect()))
     }
 
@@ -837,7 +1086,7 @@ where
             .inner
             .get_keyset_by_id(&cdk_id)
             .await
-            .map_err(FfiError::database)?;
+            .map_err(FfiError::internal)?;
         Ok(result.map(Into::into))
     }
 
@@ -846,7 +1095,7 @@ where
             .inner
             .get_mint_quote(&quote_id)
             .await
-            .map_err(FfiError::database)?;
+            .map_err(FfiError::internal)?;
         Ok(result.map(|q| q.into()))
     }
 
@@ -855,7 +1104,7 @@ where
             .inner
             .get_mint_quotes()
             .await
-            .map_err(FfiError::database)?;
+            .map_err(FfiError::internal)?;
         Ok(result.into_iter().map(|q| q.into()).collect())
     }
 
@@ -864,7 +1113,7 @@ where
             .inner
             .get_unissued_mint_quotes()
             .await
-            .map_err(FfiError::database)?;
+            .map_err(FfiError::internal)?;
         Ok(result.into_iter().map(|q| q.into()).collect())
     }
 
@@ -873,7 +1122,7 @@ where
             .inner
             .get_melt_quote(&quote_id)
             .await
-            .map_err(FfiError::database)?;
+            .map_err(FfiError::internal)?;
         Ok(result.map(|q| q.into()))
     }
 
@@ -882,7 +1131,7 @@ where
             .inner
             .get_melt_quotes()
             .await
-            .map_err(FfiError::database)?;
+            .map_err(FfiError::internal)?;
         Ok(result.into_iter().map(|q| q.into()).collect())
     }
 
@@ -892,7 +1141,7 @@ where
             .inner
             .get_keys(&cdk_id)
             .await
-            .map_err(FfiError::database)?;
+            .map_err(FfiError::internal)?;
         Ok(result.map(Into::into))
     }
 
@@ -919,7 +1168,7 @@ where
             .inner
             .get_proofs(cdk_mint_url, cdk_unit, cdk_state, cdk_spending_conditions)
             .await
-            .map_err(FfiError::database)?;
+            .map_err(FfiError::internal)?;
 
         Ok(result.into_iter().map(Into::into).collect())
     }
@@ -937,7 +1186,7 @@ where
         self.inner
             .get_balance(cdk_mint_url, cdk_unit, cdk_state)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
     }
 
     async fn get_transaction(
@@ -949,7 +1198,7 @@ where
             .inner
             .get_transaction(cdk_id)
             .await
-            .map_err(FfiError::database)?;
+            .map_err(FfiError::internal)?;
         Ok(result.map(Into::into))
     }
 
@@ -967,7 +1216,7 @@ where
             .inner
             .list_transactions(cdk_mint_url, cdk_direction, cdk_unit)
             .await
-            .map_err(FfiError::database)?;
+            .map_err(FfiError::internal)?;
 
         Ok(result.into_iter().map(Into::into).collect())
     }
@@ -981,7 +1230,7 @@ where
         self.inner
             .kv_read(&primary_namespace, &secondary_namespace, &key)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
     }
 
     async fn kv_list(
@@ -992,7 +1241,7 @@ where
         self.inner
             .kv_list(&primary_namespace, &secondary_namespace)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
     }
 
     async fn kv_write(
@@ -1005,7 +1254,7 @@ where
         self.inner
             .kv_write(&primary_namespace, &secondary_namespace, &key, &value)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
     }
 
     async fn kv_remove(
@@ -1017,7 +1266,7 @@ where
         self.inner
             .kv_remove(&primary_namespace, &secondary_namespace, &key)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
     }
 
     // ========== Write methods ==========
@@ -1040,6 +1289,16 @@ where
                         .map(|sc| sc.try_into())
                         .transpose()?,
                     unit: info.unit.into(),
+                    used_by_operation: info
+                        .used_by_operation
+                        .map(|id| uuid::Uuid::parse_str(&id))
+                        .transpose()
+                        .map_err(|e| FfiError::internal(e.to_string()))?,
+                    created_by_operation: info
+                        .created_by_operation
+                        .map(|id| uuid::Uuid::parse_str(&id))
+                        .transpose()
+                        .map_err(|e| FfiError::internal(e.to_string()))?,
                 })
             })
             .collect();
@@ -1052,7 +1311,7 @@ where
         self.inner
             .update_proofs(cdk_added, cdk_removed_ys)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
     }
 
     async fn update_proofs_state(
@@ -1068,7 +1327,7 @@ where
         self.inner
             .update_proofs_state(cdk_ys, cdk_state)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
     }
 
     async fn add_transaction(&self, transaction: Transaction) -> Result<(), FfiError> {
@@ -1076,7 +1335,7 @@ where
         self.inner
             .add_transaction(cdk_transaction)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
     }
 
     async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), FfiError> {
@@ -1084,7 +1343,7 @@ where
         self.inner
             .remove_transaction(cdk_id)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
     }
 
     async fn update_mint_url(
@@ -1097,7 +1356,7 @@ where
         self.inner
             .update_mint_url(cdk_old, cdk_new)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
     }
 
     async fn increment_keyset_counter(&self, keyset_id: Id, count: u32) -> Result<u32, FfiError> {
@@ -1105,7 +1364,7 @@ where
         self.inner
             .increment_keyset_counter(&cdk_id, count)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
     }
 
     async fn add_mint(
@@ -1118,7 +1377,7 @@ where
         self.inner
             .add_mint(cdk_mint_url, cdk_mint_info)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
     }
 
     async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), FfiError> {
@@ -1126,7 +1385,7 @@ where
         self.inner
             .remove_mint(cdk_mint_url)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
     }
 
     async fn add_mint_keysets(
@@ -1139,7 +1398,7 @@ where
         self.inner
             .add_mint_keysets(cdk_mint_url, cdk_keysets)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
     }
 
     async fn add_mint_quote(&self, quote: MintQuote) -> Result<(), FfiError> {
@@ -1147,14 +1406,14 @@ where
         self.inner
             .add_mint_quote(cdk_quote)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
     }
 
     async fn remove_mint_quote(&self, quote_id: String) -> Result<(), FfiError> {
         self.inner
             .remove_mint_quote(&quote_id)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
     }
 
     async fn add_melt_quote(&self, quote: MeltQuote) -> Result<(), FfiError> {
@@ -1162,14 +1421,14 @@ where
         self.inner
             .add_melt_quote(cdk_quote)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
     }
 
     async fn remove_melt_quote(&self, quote_id: String) -> Result<(), FfiError> {
         self.inner
             .remove_melt_quote(&quote_id)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
     }
 
     async fn add_keys(&self, keyset: KeySet) -> Result<(), FfiError> {
@@ -1177,7 +1436,7 @@ where
         self.inner
             .add_keys(cdk_keyset)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
     }
 
     async fn remove_keys(&self, id: Id) -> Result<(), FfiError> {
@@ -1185,7 +1444,134 @@ where
         self.inner
             .remove_keys(&cdk_id)
             .await
-            .map_err(FfiError::database)
+            .map_err(FfiError::internal)
+    }
+
+    // ========== Saga management methods ==========
+
+    async fn add_saga(&self, saga_json: String) -> Result<(), FfiError> {
+        let saga: WalletSaga = serde_json::from_str(&saga_json).map_err(FfiError::internal)?;
+        self.inner.add_saga(saga).await.map_err(FfiError::internal)
+    }
+
+    async fn get_saga(&self, id: String) -> Result<Option<String>, FfiError> {
+        let id = uuid::Uuid::parse_str(&id).map_err(FfiError::internal)?;
+        let result = self.inner.get_saga(&id).await.map_err(FfiError::internal)?;
+
+        match result {
+            Some(saga) => {
+                let json = serde_json::to_string(&saga).map_err(FfiError::internal)?;
+                Ok(Some(json))
+            }
+            None => Ok(None),
+        }
+    }
+
+    async fn update_saga(&self, saga_json: String) -> Result<bool, FfiError> {
+        let saga: WalletSaga = serde_json::from_str(&saga_json).map_err(FfiError::internal)?;
+        self.inner
+            .update_saga(saga)
+            .await
+            .map_err(FfiError::internal)
+    }
+
+    async fn delete_saga(&self, id: String) -> Result<(), FfiError> {
+        let id = uuid::Uuid::parse_str(&id).map_err(FfiError::internal)?;
+        self.inner
+            .delete_saga(&id)
+            .await
+            .map_err(FfiError::internal)
+    }
+
+    async fn get_incomplete_sagas(&self) -> Result<Vec<String>, FfiError> {
+        let result = self
+            .inner
+            .get_incomplete_sagas()
+            .await
+            .map_err(FfiError::internal)?;
+
+        result
+            .into_iter()
+            .map(|saga| serde_json::to_string(&saga).map_err(FfiError::internal))
+            .collect()
+    }
+
+    // ========== Proof reservation methods ==========
+
+    async fn reserve_proofs(
+        &self,
+        ys: Vec<PublicKey>,
+        operation_id: String,
+    ) -> Result<(), FfiError> {
+        let operation_id = uuid::Uuid::parse_str(&operation_id).map_err(FfiError::internal)?;
+        let cdk_ys: Result<Vec<cdk::nuts::PublicKey>, FfiError> =
+            ys.into_iter().map(|pk| pk.try_into()).collect();
+        let cdk_ys = cdk_ys?;
+        self.inner
+            .reserve_proofs(cdk_ys, &operation_id)
+            .await
+            .map_err(FfiError::internal)
+    }
+
+    async fn release_proofs(&self, operation_id: String) -> Result<(), FfiError> {
+        let operation_id = uuid::Uuid::parse_str(&operation_id).map_err(FfiError::internal)?;
+        self.inner
+            .release_proofs(&operation_id)
+            .await
+            .map_err(FfiError::internal)
+    }
+
+    async fn get_reserved_proofs(&self, operation_id: String) -> Result<Vec<ProofInfo>, FfiError> {
+        let operation_id = uuid::Uuid::parse_str(&operation_id).map_err(FfiError::internal)?;
+        let result = self
+            .inner
+            .get_reserved_proofs(&operation_id)
+            .await
+            .map_err(FfiError::internal)?;
+
+        Ok(result.into_iter().map(Into::into).collect())
+    }
+
+    // ========== Quote reservation methods ==========
+
+    async fn reserve_melt_quote(
+        &self,
+        quote_id: String,
+        operation_id: String,
+    ) -> Result<(), FfiError> {
+        let operation_id = uuid::Uuid::parse_str(&operation_id).map_err(FfiError::internal)?;
+        self.inner
+            .reserve_melt_quote(&quote_id, &operation_id)
+            .await
+            .map_err(FfiError::internal)
+    }
+
+    async fn release_melt_quote(&self, operation_id: String) -> Result<(), FfiError> {
+        let operation_id = uuid::Uuid::parse_str(&operation_id).map_err(FfiError::internal)?;
+        self.inner
+            .release_melt_quote(&operation_id)
+            .await
+            .map_err(FfiError::internal)
+    }
+
+    async fn reserve_mint_quote(
+        &self,
+        quote_id: String,
+        operation_id: String,
+    ) -> Result<(), FfiError> {
+        let operation_id = uuid::Uuid::parse_str(&operation_id).map_err(FfiError::internal)?;
+        self.inner
+            .reserve_mint_quote(&quote_id, &operation_id)
+            .await
+            .map_err(FfiError::internal)
+    }
+
+    async fn release_mint_quote(&self, operation_id: String) -> Result<(), FfiError> {
+        let operation_id = uuid::Uuid::parse_str(&operation_id).map_err(FfiError::internal)?;
+        self.inner
+            .release_mint_quote(&operation_id)
+            .await
+            .map_err(FfiError::internal)
     }
 }
 
@@ -1437,6 +1823,75 @@ macro_rules! impl_ffi_wallet_database {
             async fn remove_keys(&self, id: Id) -> Result<(), FfiError> {
                 self.inner.remove_keys(id).await
             }
+
+            // ========== Saga management methods ==========
+
+            async fn add_saga(&self, saga_json: String) -> Result<(), FfiError> {
+                self.inner.add_saga(saga_json).await
+            }
+
+            async fn get_saga(&self, id: String) -> Result<Option<String>, FfiError> {
+                self.inner.get_saga(id).await
+            }
+
+            async fn update_saga(&self, saga_json: String) -> Result<bool, FfiError> {
+                self.inner.update_saga(saga_json).await
+            }
+
+            async fn delete_saga(&self, id: String) -> Result<(), FfiError> {
+                self.inner.delete_saga(id).await
+            }
+
+            async fn get_incomplete_sagas(&self) -> Result<Vec<String>, FfiError> {
+                self.inner.get_incomplete_sagas().await
+            }
+
+            // ========== Proof reservation methods ==========
+
+            async fn reserve_proofs(
+                &self,
+                ys: Vec<PublicKey>,
+                operation_id: String,
+            ) -> Result<(), FfiError> {
+                self.inner.reserve_proofs(ys, operation_id).await
+            }
+
+            async fn release_proofs(&self, operation_id: String) -> Result<(), FfiError> {
+                self.inner.release_proofs(operation_id).await
+            }
+
+            async fn get_reserved_proofs(
+                &self,
+                operation_id: String,
+            ) -> Result<Vec<ProofInfo>, FfiError> {
+                self.inner.get_reserved_proofs(operation_id).await
+            }
+
+            // ========== Quote reservation methods ==========
+
+            async fn reserve_melt_quote(
+                &self,
+                quote_id: String,
+                operation_id: String,
+            ) -> Result<(), FfiError> {
+                self.inner.reserve_melt_quote(quote_id, operation_id).await
+            }
+
+            async fn release_melt_quote(&self, operation_id: String) -> Result<(), FfiError> {
+                self.inner.release_melt_quote(operation_id).await
+            }
+
+            async fn reserve_mint_quote(
+                &self,
+                quote_id: String,
+                operation_id: String,
+            ) -> Result<(), FfiError> {
+                self.inner.reserve_mint_quote(quote_id, operation_id).await
+            }
+
+            async fn release_mint_quote(&self, operation_id: String) -> Result<(), FfiError> {
+                self.inner.release_mint_quote(operation_id).await
+            }
         }
     };
 }

+ 90 - 28
crates/cdk-ffi/src/multi_mint_wallet.rs

@@ -292,19 +292,60 @@ impl MultiMintWallet {
         Ok(restored.into())
     }
 
-    /// Prepare a send operation from a specific mint
-    pub async fn prepare_send(
+    /// Get all pending send operations across all mints
+    pub async fn get_pending_sends(&self) -> Result<Vec<PendingSend>, FfiError> {
+        let sends = self.inner.get_pending_sends().await?;
+        Ok(sends
+            .into_iter()
+            .map(|(mint_url, id)| PendingSend {
+                mint_url: mint_url.into(),
+                operation_id: id.to_string(),
+            })
+            .collect())
+    }
+
+    /// Revoke a pending send operation for a specific mint
+    pub async fn revoke_send(
+        &self,
+        mint_url: MintUrl,
+        operation_id: String,
+    ) -> Result<Amount, FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        let uuid = uuid::Uuid::parse_str(&operation_id)
+            .map_err(|e| FfiError::internal(format!("Invalid operation ID: {}", e)))?;
+        let amount = self.inner.revoke_send(cdk_mint_url, uuid).await?;
+        Ok(amount.into())
+    }
+
+    /// Check status of a pending send operation for a specific mint
+    pub async fn check_send_status(
+        &self,
+        mint_url: MintUrl,
+        operation_id: String,
+    ) -> Result<bool, FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        let uuid = uuid::Uuid::parse_str(&operation_id)
+            .map_err(|e| FfiError::internal(format!("Invalid operation ID: {}", e)))?;
+        let claimed = self.inner.check_send_status(cdk_mint_url, uuid).await?;
+        Ok(claimed)
+    }
+
+    /// Send tokens from a specific mint
+    ///
+    /// This method prepares and confirms the send in one step.
+    /// For more control over the send process, use the single-mint Wallet.
+    pub async fn send(
         &self,
         mint_url: MintUrl,
         amount: Amount,
         options: MultiMintSendOptions,
-    ) -> Result<Arc<PreparedSend>, FfiError> {
+    ) -> Result<Token, FfiError> {
         let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let prepared = self
+        let token = self
             .inner
-            .prepare_send(cdk_mint_url, amount.into(), options.into())
+            .send(cdk_mint_url, amount.into(), options.into())
             .await?;
-        Ok(Arc::new(prepared.into()))
+        Ok(token.into())
     }
 
     /// Get a mint quote from a specific mint
@@ -322,8 +363,10 @@ impl MultiMintWallet {
         Ok(quote.into())
     }
 
-    /// Check a specific mint quote status
-    pub async fn check_mint_quote(
+    /// Refresh a specific mint quote status from the mint.
+    /// Updates local store with current state from mint.
+    /// Does NOT mint tokens - use mint() to mint a specific quote.
+    pub async fn refresh_mint_quote(
         &self,
         mint_url: MintUrl,
         quote_id: String,
@@ -331,7 +374,7 @@ impl MultiMintWallet {
         let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
         let quote = self
             .inner
-            .check_mint_quote(&cdk_mint_url, &quote_id)
+            .refresh_mint_quote(&cdk_mint_url, &quote_id)
             .await?;
         Ok(quote.into())
     }
@@ -488,10 +531,10 @@ impl MultiMintWallet {
         &self,
         mint_url: MintUrl,
         quote_id: String,
-    ) -> Result<Melted, FfiError> {
+    ) -> Result<FinalizedMelt, FfiError> {
         let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let melted = self.inner.melt_with_mint(&cdk_mint_url, &quote_id).await?;
-        Ok(melted.into())
+        let finalized = self.inner.melt_with_mint(&cdk_mint_url, &quote_id).await?;
+        Ok(finalized.into())
     }
 
     /// Melt specific proofs from a specific mint
@@ -508,23 +551,23 @@ impl MultiMintWallet {
     ///
     /// # Returns
     ///
-    /// A `Melted` result containing the payment details and any change proofs
+    /// A `FinalizedMelt` result containing the payment details and any change proofs
     pub async fn melt_proofs(
         &self,
         mint_url: MintUrl,
         quote_id: String,
         proofs: Proofs,
-    ) -> Result<Melted, FfiError> {
+    ) -> Result<FinalizedMelt, FfiError> {
         let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
         let cdk_proofs: Result<Vec<cdk::nuts::Proof>, _> =
             proofs.into_iter().map(|p| p.try_into()).collect();
         let cdk_proofs = cdk_proofs?;
 
-        let melted = self
+        let finalized = self
             .inner
             .melt_proofs(&cdk_mint_url, &quote_id, cdk_proofs)
             .await?;
-        Ok(melted.into())
+        Ok(finalized.into())
     }
 
     /// Check melt quote status
@@ -534,11 +577,11 @@ impl MultiMintWallet {
         quote_id: String,
     ) -> Result<MeltQuote, FfiError> {
         let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let melted = self
+        let quote = self
             .inner
             .check_melt_quote(&cdk_mint_url, &quote_id)
             .await?;
-        Ok(melted.into())
+        Ok(quote.into())
     }
 
     /// Melt tokens (pay a bolt11 invoice)
@@ -547,11 +590,11 @@ impl MultiMintWallet {
         bolt11: String,
         options: Option<MeltOptions>,
         max_fee: Option<Amount>,
-    ) -> Result<Melted, FfiError> {
+    ) -> Result<FinalizedMelt, FfiError> {
         let cdk_options = options.map(Into::into);
         let cdk_max_fee = max_fee.map(Into::into);
-        let melted = self.inner.melt(&bolt11, cdk_options, cdk_max_fee).await?;
-        Ok(melted.into())
+        let finalized = self.inner.melt(&bolt11, cdk_options, cdk_max_fee).await?;
+        Ok(finalized.into())
     }
 
     /// Transfer funds between mints
@@ -617,13 +660,25 @@ impl MultiMintWallet {
         Ok(proofs.into_iter().map(Into::into).collect())
     }
 
-    /// Check all mint quotes and mint if paid
-    pub async fn check_all_mint_quotes(
+    /// Refresh all unissued mint quote states
+    /// Does NOT mint - use mint_unissued_quotes() for that
+    pub async fn refresh_all_mint_quotes(
+        &self,
+        mint_url: Option<MintUrl>,
+    ) -> Result<Vec<MintQuote>, FfiError> {
+        let cdk_mint_url = mint_url.map(|url| url.try_into()).transpose()?;
+        let quotes = self.inner.refresh_all_mint_quotes(cdk_mint_url).await?;
+        Ok(quotes.into_iter().map(Into::into).collect())
+    }
+
+    /// Refresh states and mint all unissued quotes
+    /// Returns total amount minted across all wallets
+    pub async fn mint_unissued_quotes(
         &self,
         mint_url: Option<MintUrl>,
     ) -> Result<Amount, FfiError> {
         let cdk_mint_url = mint_url.map(|url| url.try_into()).transpose()?;
-        let amount = self.inner.check_all_mint_quotes(cdk_mint_url).await?;
+        let amount = self.inner.mint_unissued_quotes(cdk_mint_url).await?;
         Ok(amount.into())
     }
 
@@ -644,7 +699,7 @@ impl MultiMintWallet {
         let wallets = self.inner.get_wallets().await;
         wallets
             .into_iter()
-            .map(|w| Arc::new(crate::wallet::Wallet::from_inner(Arc::new(w))))
+            .map(|w| Arc::new(crate::wallet::Wallet::from_inner(w)))
             .collect()
     }
 
@@ -652,9 +707,7 @@ impl MultiMintWallet {
     pub async fn get_wallet(&self, mint_url: MintUrl) -> Option<Arc<crate::wallet::Wallet>> {
         let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into().ok()?;
         let wallet = self.inner.get_wallet(&cdk_mint_url).await?;
-        Some(Arc::new(crate::wallet::Wallet::from_inner(Arc::new(
-            wallet,
-        ))))
+        Some(Arc::new(crate::wallet::Wallet::from_inner(wallet)))
     }
 
     /// Verify token DLEQ proofs
@@ -917,6 +970,15 @@ impl From<CdkTransferResult> for TransferResult {
     }
 }
 
+/// Represents a pending send operation
+#[derive(Debug, Clone, uniffi::Record)]
+pub struct PendingSend {
+    /// The mint URL where the send is pending
+    pub mint_url: MintUrl,
+    /// The operation ID of the pending send
+    pub operation_id: String,
+}
+
 /// Data extracted from a token including mint URL, proofs, and memo
 #[derive(Debug, Clone, uniffi::Record)]
 pub struct TokenData {

+ 1 - 1
crates/cdk-ffi/src/postgres.rs

@@ -50,7 +50,7 @@ impl WalletPostgresDatabase {
             Err(_) => pg_runtime()
                 .block_on(async move { cdk_postgres::new_wallet_pg_database(url.as_str()).await }),
         }
-        .map_err(FfiError::database)?;
+        .map_err(FfiError::internal)?;
         Ok(Arc::new(WalletPostgresDatabase {
             inner: FfiWalletSQLDatabase::new(inner),
         }))

+ 2 - 2
crates/cdk-ffi/src/sqlite.rs

@@ -32,7 +32,7 @@ impl WalletSqliteDatabase {
                     .block_on(async move { CdkWalletSqliteDatabase::new(file_path.as_str()).await })
             }
         }
-        .map_err(FfiError::database)?;
+        .map_err(FfiError::internal)?;
         Ok(Arc::new(Self {
             inner: FfiWalletSQLDatabase::new(db),
         }))
@@ -52,7 +52,7 @@ impl WalletSqliteDatabase {
                     .block_on(async move { cdk_sqlite::wallet::memory::empty().await })
             }
         }
-        .map_err(FfiError::database)?;
+        .map_err(FfiError::internal)?;
         Ok(Arc::new(Self {
             inner: FfiWalletSQLDatabase::new(db),
         }))

+ 17 - 0
crates/cdk-ffi/src/types/proof.rs

@@ -470,6 +470,10 @@ pub struct ProofInfo {
     pub spending_condition: Option<SpendingConditions>,
     /// Currency unit
     pub unit: CurrencyUnit,
+    /// Operation ID that is using/spending this proof
+    pub used_by_operation: Option<String>,
+    /// Operation ID that created this proof
+    pub created_by_operation: Option<String>,
 }
 
 impl From<cdk::types::ProofInfo> for ProofInfo {
@@ -481,6 +485,8 @@ impl From<cdk::types::ProofInfo> for ProofInfo {
             state: info.state.into(),
             spending_condition: info.spending_condition.map(Into::into),
             unit: info.unit.into(),
+            used_by_operation: info.used_by_operation.map(|u| u.to_string()),
+            created_by_operation: info.created_by_operation.map(|u| u.to_string()),
         }
     }
 }
@@ -495,6 +501,7 @@ pub fn decode_proof_info(json: String) -> Result<ProofInfo, FfiError> {
 /// Encode ProofInfo to JSON string
 #[uniffi::export]
 pub fn encode_proof_info(info: ProofInfo) -> Result<String, FfiError> {
+    use std::str::FromStr;
     // Convert to cdk::types::ProofInfo for serialization
     let cdk_info = cdk::types::ProofInfo {
         proof: info.proof.try_into()?,
@@ -503,6 +510,16 @@ pub fn encode_proof_info(info: ProofInfo) -> Result<String, FfiError> {
         state: info.state.into(),
         spending_condition: info.spending_condition.and_then(|c| c.try_into().ok()),
         unit: info.unit.into(),
+        used_by_operation: info
+            .used_by_operation
+            .map(|id| uuid::Uuid::from_str(&id))
+            .transpose()
+            .map_err(|e| FfiError::internal(e.to_string()))?,
+        created_by_operation: info
+            .created_by_operation
+            .map(|id| uuid::Uuid::from_str(&id))
+            .transpose()
+            .map_err(|e| FfiError::internal(e.to_string()))?,
     };
     Ok(serde_json::to_string(&cdk_info)?)
 }

+ 32 - 0
crates/cdk-ffi/src/types/quote.rs

@@ -31,6 +31,11 @@ pub struct MintQuote {
     pub payment_method: PaymentMethod,
     /// Secret key (optional, hex-encoded)
     pub secret_key: Option<String>,
+    /// Operation ID that reserved this quote
+    pub used_by_operation: Option<String>,
+    /// Version for optimistic locking
+    #[serde(default)]
+    pub version: u32,
 }
 
 impl From<cdk::wallet::MintQuote> for MintQuote {
@@ -47,6 +52,8 @@ impl From<cdk::wallet::MintQuote> for MintQuote {
             amount_paid: quote.amount_paid.into(),
             payment_method: quote.payment_method.into(),
             secret_key: quote.secret_key.map(|sk| sk.to_secret_hex()),
+            used_by_operation: quote.used_by_operation.map(|id| id.to_string()),
+            version: quote.version,
         }
     }
 }
@@ -73,6 +80,8 @@ impl TryFrom<MintQuote> for cdk::wallet::MintQuote {
             amount_paid: quote.amount_paid.into(),
             payment_method: quote.payment_method.into(),
             secret_key,
+            used_by_operation: quote.used_by_operation,
+            version: quote.version,
         })
     }
 }
@@ -144,6 +153,20 @@ impl From<cdk::nuts::MintQuoteBolt11Response<String>> for MintQuoteBolt11Respons
     }
 }
 
+impl From<cdk::wallet::MintQuote> for MintQuoteBolt11Response {
+    fn from(quote: cdk::wallet::MintQuote) -> Self {
+        Self {
+            quote: quote.id,
+            request: quote.request,
+            state: quote.state.into(),
+            expiry: Some(quote.expiry),
+            amount: quote.amount.map(Into::into),
+            unit: Some(quote.unit.into()),
+            pubkey: quote.secret_key.map(|sk| sk.public_key().to_string()),
+        }
+    }
+}
+
 /// FFI-compatible MintQuoteCustomResponse
 ///
 /// This is a unified response type for custom payment methods that includes
@@ -333,6 +356,11 @@ pub struct MeltQuote {
     pub payment_preimage: Option<String>,
     /// Payment method
     pub payment_method: PaymentMethod,
+    /// Operation ID that reserved this quote
+    pub used_by_operation: Option<String>,
+    /// Version for optimistic locking
+    #[serde(default)]
+    pub version: u32,
 }
 
 impl From<cdk::wallet::MeltQuote> for MeltQuote {
@@ -347,6 +375,8 @@ impl From<cdk::wallet::MeltQuote> for MeltQuote {
             expiry: quote.expiry,
             payment_preimage: quote.payment_preimage.clone(),
             payment_method: quote.payment_method.into(),
+            used_by_operation: quote.used_by_operation.map(|id| id.to_string()),
+            version: quote.version,
         }
     }
 }
@@ -365,6 +395,8 @@ impl TryFrom<MeltQuote> for cdk::wallet::MeltQuote {
             expiry: quote.expiry,
             payment_preimage: quote.payment_preimage,
             payment_method: quote.payment_method.into(),
+            used_by_operation: quote.used_by_operation,
+            version: quote.version,
         })
     }
 }

+ 6 - 0
crates/cdk-ffi/src/types/transaction.rs

@@ -1,8 +1,10 @@
 //! Transaction-related FFI types
 
 use std::collections::HashMap;
+use std::str::FromStr;
 
 use serde::{Deserialize, Serialize};
+use uuid::Uuid;
 
 use super::amount::{Amount, CurrencyUnit};
 use super::keys::PublicKey;
@@ -42,6 +44,8 @@ pub struct Transaction {
     pub payment_proof: Option<String>,
     /// Payment method (e.g., Bolt11, Bolt12) for mint/melt transactions
     pub payment_method: Option<PaymentMethod>,
+    /// Saga ID if this transaction was part of a saga
+    pub saga_id: Option<String>,
 }
 
 impl From<cdk::wallet::types::Transaction> for Transaction {
@@ -61,6 +65,7 @@ impl From<cdk::wallet::types::Transaction> for Transaction {
             payment_request: tx.payment_request,
             payment_proof: tx.payment_proof,
             payment_method: tx.payment_method.map(Into::into),
+            saga_id: tx.saga_id.map(|id| id.to_string()),
         }
     }
 }
@@ -88,6 +93,7 @@ impl TryFrom<Transaction> for cdk::wallet::types::Transaction {
             payment_request: tx.payment_request,
             payment_proof: tx.payment_proof,
             payment_method: tx.payment_method.map(Into::into),
+            saga_id: tx.saga_id.and_then(|id| Uuid::from_str(&id).ok()),
         })
     }
 }

+ 280 - 77
crates/cdk-ffi/src/types/wallet.rs

@@ -1,7 +1,6 @@
 //! Wallet-related FFI types
 
 use std::collections::HashMap;
-use std::sync::Mutex;
 
 use serde::{Deserialize, Serialize};
 
@@ -298,38 +297,55 @@ pub fn encode_receive_options(options: ReceiveOptions) -> Result<String, FfiErro
 }
 
 /// FFI-compatible PreparedSend
-#[derive(Debug, uniffi::Object)]
+///
+/// This wraps the data from a prepared send operation along with a reference
+/// to the wallet. The actual PreparedSend<'a> from cdk has a lifetime parameter
+/// that doesn't work with FFI, so we store the wallet and cached data separately.
+#[derive(uniffi::Object)]
 pub struct PreparedSend {
-    inner: Mutex<Option<cdk::wallet::PreparedSend>>,
-    id: String,
+    wallet: std::sync::Arc<cdk::Wallet>,
+    operation_id: uuid::Uuid,
     amount: Amount,
-    proofs: Proofs,
+    options: cdk::wallet::SendOptions,
+    proofs_to_swap: cdk::nuts::Proofs,
+    proofs_to_send: cdk::nuts::Proofs,
+    swap_fee: Amount,
+    send_fee: Amount,
+}
+
+impl std::fmt::Debug for PreparedSend {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("PreparedSend")
+            .field("operation_id", &self.operation_id)
+            .field("amount", &self.amount)
+            .finish()
+    }
 }
 
-impl From<cdk::wallet::PreparedSend> for PreparedSend {
-    fn from(prepared: cdk::wallet::PreparedSend) -> Self {
-        let id = format!("{:?}", prepared); // Use debug format as ID
-        let amount = prepared.amount().into();
-        let proofs = prepared
-            .proofs()
-            .iter()
-            .cloned()
-            .map(|p| p.into())
-            .collect();
+impl PreparedSend {
+    /// Create a new FFI PreparedSend from a cdk::wallet::PreparedSend and wallet
+    pub fn new(
+        wallet: std::sync::Arc<cdk::Wallet>,
+        prepared: &cdk::wallet::PreparedSend<'_>,
+    ) -> Self {
         Self {
-            inner: Mutex::new(Some(prepared)),
-            id,
-            amount,
-            proofs,
+            wallet,
+            operation_id: prepared.operation_id(),
+            amount: prepared.amount().into(),
+            options: prepared.options().clone(),
+            proofs_to_swap: prepared.proofs_to_swap().clone(),
+            proofs_to_send: prepared.proofs_to_send().clone(),
+            swap_fee: prepared.swap_fee().into(),
+            send_fee: prepared.send_fee().into(),
         }
     }
 }
 
 #[uniffi::export(async_runtime = "tokio")]
 impl PreparedSend {
-    /// Get the prepared send ID
-    pub fn id(&self) -> String {
-        self.id.clone()
+    /// Get the operation ID for this prepared send
+    pub fn operation_id(&self) -> String {
+        self.operation_id.to_string()
     }
 
     /// Get the amount to send
@@ -339,20 +355,19 @@ impl PreparedSend {
 
     /// Get the proofs that will be used
     pub fn proofs(&self) -> Proofs {
-        self.proofs.clone()
+        let mut all_proofs: Vec<_> = self
+            .proofs_to_swap
+            .iter()
+            .cloned()
+            .map(|p| p.into())
+            .collect();
+        all_proofs.extend(self.proofs_to_send.iter().cloned().map(|p| p.into()));
+        all_proofs
     }
 
     /// Get the total fee for this send operation
     pub fn fee(&self) -> Amount {
-        if let Ok(guard) = self.inner.lock() {
-            if let Some(ref inner) = *guard {
-                inner.fee().into()
-            } else {
-                Amount::new(0)
-            }
-        } else {
-            Amount::new(0)
-        }
+        Amount::new(self.swap_fee.value + self.send_fee.value)
     }
 
     /// Confirm the prepared send and create a token
@@ -360,49 +375,41 @@ impl PreparedSend {
         self: std::sync::Arc<Self>,
         memo: Option<String>,
     ) -> Result<Token, FfiError> {
-        let inner = {
-            if let Ok(mut guard) = self.inner.lock() {
-                guard.take()
-            } else {
-                return Err(FfiError::internal("Failed to acquire lock on PreparedSend"));
-            }
-        };
-
-        if let Some(inner) = inner {
-            let send_memo = memo.map(|m| cdk::wallet::SendMemo::for_token(&m));
-            let token = inner.confirm(send_memo).await?;
-            Ok(token.into())
-        } else {
-            Err(FfiError::internal(
-                "PreparedSend has already been consumed or cancelled",
-            ))
-        }
+        let send_memo = memo.map(|m| cdk::wallet::SendMemo::for_token(&m));
+        let token = self
+            .wallet
+            .confirm_send(
+                self.operation_id,
+                self.amount.into(),
+                self.options.clone(),
+                self.proofs_to_swap.clone(),
+                self.proofs_to_send.clone(),
+                self.swap_fee.into(),
+                self.send_fee.into(),
+                send_memo,
+            )
+            .await?;
+
+        Ok(token.into())
     }
 
     /// Cancel the prepared send operation
     pub async fn cancel(self: std::sync::Arc<Self>) -> Result<(), FfiError> {
-        let inner = {
-            if let Ok(mut guard) = self.inner.lock() {
-                guard.take()
-            } else {
-                return Err(FfiError::internal("Failed to acquire lock on PreparedSend"));
-            }
-        };
-
-        if let Some(inner) = inner {
-            inner.cancel().await?;
-            Ok(())
-        } else {
-            Err(FfiError::internal(
-                "PreparedSend has already been consumed or cancelled",
-            ))
-        }
+        self.wallet
+            .cancel_send(
+                self.operation_id,
+                self.proofs_to_swap.clone(),
+                self.proofs_to_send.clone(),
+            )
+            .await?;
+        Ok(())
     }
 }
 
-/// FFI-compatible Melted result
+/// FFI-compatible FinalizedMelt result
 #[derive(Debug, Clone, uniffi::Record)]
-pub struct Melted {
+pub struct FinalizedMelt {
+    pub quote_id: String,
     pub state: super::quote::QuoteState,
     pub preimage: Option<String>,
     pub change: Option<Proofs>,
@@ -410,22 +417,194 @@ pub struct Melted {
     pub fee_paid: Amount,
 }
 
-// MeltQuoteState is just an alias for nut05::QuoteState, so we don't need a separate implementation
+impl From<cdk_common::common::FinalizedMelt> for FinalizedMelt {
+    fn from(finalized: cdk_common::common::FinalizedMelt) -> Self {
+        Self {
+            quote_id: finalized.quote_id().to_string(),
+            state: finalized.state().into(),
+            preimage: finalized.payment_proof().map(|s: &str| s.to_string()),
+            change: finalized
+                .change()
+                .map(|proofs| proofs.iter().cloned().map(|p| p.into()).collect()),
+            amount: finalized.amount().into(),
+            fee_paid: finalized.fee_paid().into(),
+        }
+    }
+}
+
+/// FFI-compatible PreparedMelt
+///
+/// This wraps the data from a prepared melt operation along with a reference
+/// to the wallet. The actual PreparedMelt<'a> from cdk has a lifetime parameter
+/// that doesn't work with FFI, so we store the wallet and cached data separately.
+#[derive(uniffi::Object)]
+pub struct PreparedMelt {
+    wallet: std::sync::Arc<cdk::Wallet>,
+    operation_id: uuid::Uuid,
+    quote: cdk_common::wallet::MeltQuote,
+    proofs: cdk::nuts::Proofs,
+    proofs_to_swap: cdk::nuts::Proofs,
+    swap_fee: Amount,
+    input_fee: Amount,
+    input_fee_without_swap: Amount,
+    metadata: HashMap<String, String>,
+}
+
+impl std::fmt::Debug for PreparedMelt {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("PreparedMelt")
+            .field("operation_id", &self.operation_id)
+            .field("quote_id", &self.quote.id)
+            .field("amount", &self.quote.amount)
+            .finish()
+    }
+}
 
-impl From<cdk::types::Melted> for Melted {
-    fn from(melted: cdk::types::Melted) -> Self {
+impl PreparedMelt {
+    /// Create a new FFI PreparedMelt from a cdk::wallet::PreparedMelt and wallet
+    pub fn new(
+        wallet: std::sync::Arc<cdk::Wallet>,
+        prepared: &cdk::wallet::PreparedMelt<'_>,
+    ) -> Self {
         Self {
-            state: melted.state.into(),
-            preimage: melted.preimage,
-            change: melted
-                .change
-                .map(|proofs| proofs.into_iter().map(|p| p.into()).collect()),
-            amount: melted.amount.into(),
-            fee_paid: melted.fee_paid.into(),
+            wallet,
+            operation_id: prepared.operation_id(),
+            quote: prepared.quote().clone(),
+            proofs: prepared.proofs().clone(),
+            proofs_to_swap: prepared.proofs_to_swap().clone(),
+            swap_fee: prepared.swap_fee().into(),
+            input_fee: prepared.input_fee().into(),
+            input_fee_without_swap: prepared.input_fee_without_swap().into(),
+            metadata: HashMap::new(),
         }
     }
 }
 
+#[uniffi::export(async_runtime = "tokio")]
+impl PreparedMelt {
+    /// Get the operation ID for this prepared melt
+    pub fn operation_id(&self) -> String {
+        self.operation_id.to_string()
+    }
+
+    /// Get the quote ID
+    pub fn quote_id(&self) -> String {
+        self.quote.id.clone()
+    }
+
+    /// Get the amount to be melted
+    pub fn amount(&self) -> Amount {
+        self.quote.amount.into()
+    }
+
+    /// Get the fee reserve from the quote
+    pub fn fee_reserve(&self) -> Amount {
+        self.quote.fee_reserve.into()
+    }
+
+    /// Get the swap fee
+    pub fn swap_fee(&self) -> Amount {
+        self.swap_fee
+    }
+
+    /// Get the input fee
+    pub fn input_fee(&self) -> Amount {
+        self.input_fee
+    }
+
+    /// Get the total fee (swap fee + input fee)
+    pub fn total_fee(&self) -> Amount {
+        Amount::new(self.swap_fee.value + self.input_fee.value)
+    }
+
+    /// Returns true if a swap would be performed (proofs_to_swap is not empty)
+    pub fn requires_swap(&self) -> bool {
+        !self.proofs_to_swap.is_empty()
+    }
+
+    /// Get the total fee if swap is performed (current default behavior)
+    pub fn total_fee_with_swap(&self) -> Amount {
+        Amount::new(self.swap_fee.value + self.input_fee.value)
+    }
+
+    /// Get the input fee if swap is skipped (fee on all proofs sent directly)
+    pub fn input_fee_without_swap(&self) -> Amount {
+        self.input_fee_without_swap
+    }
+
+    /// Get the fee savings from skipping the swap
+    pub fn fee_savings_without_swap(&self) -> Amount {
+        let total_with = self.swap_fee.value + self.input_fee.value;
+        let total_without = self.input_fee_without_swap.value;
+        if total_with > total_without {
+            Amount::new(total_with - total_without)
+        } else {
+            Amount::new(0)
+        }
+    }
+
+    /// Get the expected change amount if swap is skipped
+    pub fn change_amount_without_swap(&self) -> Amount {
+        use cdk::nuts::nut00::ProofsMethods;
+        let all_proofs_total = self.proofs.total_amount().unwrap_or(cdk::Amount::ZERO)
+            + self
+                .proofs_to_swap
+                .total_amount()
+                .unwrap_or(cdk::Amount::ZERO);
+        let needed =
+            self.quote.amount + self.quote.fee_reserve + self.input_fee_without_swap.into();
+        all_proofs_total
+            .checked_sub(needed)
+            .map(|a| a.into())
+            .unwrap_or(Amount::new(0))
+    }
+
+    /// Get the proofs that will be used
+    pub fn proofs(&self) -> Proofs {
+        self.proofs.iter().cloned().map(|p| p.into()).collect()
+    }
+
+    /// Confirm the prepared melt and execute the payment
+    pub async fn confirm(&self) -> Result<FinalizedMelt, FfiError> {
+        self.confirm_with_options(MeltConfirmOptions::default())
+            .await
+    }
+
+    /// Confirm the prepared melt with custom options
+    pub async fn confirm_with_options(
+        &self,
+        options: MeltConfirmOptions,
+    ) -> Result<FinalizedMelt, FfiError> {
+        let finalized = self
+            .wallet
+            .confirm_prepared_melt_with_options(
+                self.operation_id,
+                self.quote.clone(),
+                self.proofs.clone(),
+                self.proofs_to_swap.clone(),
+                self.input_fee.into(),
+                self.input_fee_without_swap.into(),
+                self.metadata.clone(),
+                options.into(),
+            )
+            .await?;
+
+        Ok(finalized.into())
+    }
+
+    /// Cancel the prepared melt and release reserved proofs
+    pub async fn cancel(&self) -> Result<(), FfiError> {
+        self.wallet
+            .cancel_prepared_melt(
+                self.operation_id,
+                self.proofs.clone(),
+                self.proofs_to_swap.clone(),
+            )
+            .await?;
+        Ok(())
+    }
+}
+
 /// FFI-compatible MeltOptions
 #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)]
 pub enum MeltOptions {
@@ -480,3 +659,27 @@ impl From<cdk::wallet::Restored> for Restored {
         }
     }
 }
+
+/// FFI-compatible options for confirming a melt operation
+#[derive(Debug, Clone, Default, Serialize, Deserialize, uniffi::Record)]
+pub struct MeltConfirmOptions {
+    /// Skip the pre-melt swap and send proofs directly to melt.
+    /// When true, saves swap input fees but gets change from melt instead.
+    pub skip_swap: bool,
+}
+
+impl From<MeltConfirmOptions> for cdk::wallet::MeltConfirmOptions {
+    fn from(opts: MeltConfirmOptions) -> Self {
+        cdk::wallet::MeltConfirmOptions {
+            skip_swap: opts.skip_swap,
+        }
+    }
+}
+
+impl From<cdk::wallet::MeltConfirmOptions> for MeltConfirmOptions {
+    fn from(opts: cdk::wallet::MeltConfirmOptions) -> Self {
+        Self {
+            skip_swap: opts.skip_swap,
+        }
+    }
+}

+ 79 - 78
crates/cdk-ffi/src/wallet.rs

@@ -156,6 +156,7 @@ impl Wallet {
         proofs: Proofs,
         options: ReceiveOptions,
         memo: Option<String>,
+        token: Option<String>,
     ) -> Result<Amount, FfiError> {
         let cdk_proofs: Result<Vec<cdk::nuts::Proof>, _> =
             proofs.into_iter().map(|p| p.try_into()).collect();
@@ -163,11 +164,33 @@ impl Wallet {
 
         let amount = self
             .inner
-            .receive_proofs(cdk_proofs, options.into(), memo)
+            .receive_proofs(cdk_proofs, options.into(), memo, token)
             .await?;
         Ok(amount.into())
     }
 
+    /// Get all pending send operations
+    pub async fn get_pending_sends(&self) -> Result<Vec<String>, FfiError> {
+        let sends = self.inner.get_pending_sends().await?;
+        Ok(sends.into_iter().map(|id| id.to_string()).collect())
+    }
+
+    /// Revoke a pending send operation
+    pub async fn revoke_send(&self, operation_id: String) -> Result<Amount, FfiError> {
+        let uuid = uuid::Uuid::parse_str(&operation_id)
+            .map_err(|e| FfiError::internal(format!("Invalid operation ID: {}", e)))?;
+        let amount = self.inner.revoke_send(uuid).await?;
+        Ok(amount.into())
+    }
+
+    /// Check status of a pending send operation
+    pub async fn check_send_status(&self, operation_id: String) -> Result<bool, FfiError> {
+        let uuid = uuid::Uuid::parse_str(&operation_id)
+            .map_err(|e| FfiError::internal(format!("Invalid operation ID: {}", e)))?;
+        let claimed = self.inner.check_send_status(uuid).await?;
+        Ok(claimed)
+    }
+
     /// Prepare a send operation
     pub async fn prepare_send(
         &self,
@@ -178,25 +201,35 @@ impl Wallet {
             .inner
             .prepare_send(amount.into(), options.into())
             .await?;
-        Ok(std::sync::Arc::new(prepared.into()))
+        Ok(std::sync::Arc::new(PreparedSend::new(
+            self.inner.clone(),
+            &prepared,
+        )))
     }
 
     /// Get a mint quote
     pub async fn mint_quote(
         &self,
-        amount: Amount,
+        payment_method: PaymentMethod,
+        amount: Option<Amount>,
         description: Option<String>,
+        extra: Option<String>,
     ) -> Result<MintQuote, FfiError> {
-        let quote = self.inner.mint_quote(amount.into(), description).await?;
+        let quote = self
+            .inner
+            .mint_quote(payment_method, amount.map(Into::into), description, extra)
+            .await?;
         Ok(quote.into())
     }
 
-    /// Check a specific mint quote status
-    pub async fn check_mint_quote(
+    /// Refresh a specific mint quote status from the mint.
+    /// Updates local store with current state from mint.
+    /// Does NOT mint tokens - use mint() to mint a specific quote.
+    pub async fn refresh_mint_quote(
         &self,
         quote_id: String,
     ) -> Result<MintQuoteBolt11Response, FfiError> {
-        let quote = self.inner.mint_quote_state(&quote_id).await?;
+        let quote = self.inner.refresh_mint_quote_status(&quote_id).await?;
         Ok(quote.into())
     }
 
@@ -218,23 +251,31 @@ impl Wallet {
     }
 
     /// Get a melt quote
-    pub async fn melt_quote(
+    pub async fn melt_bolt11_quote(
         &self,
         request: String,
         options: Option<MeltOptions>,
     ) -> Result<MeltQuote, FfiError> {
         let cdk_options = options.map(Into::into);
-        let quote = self.inner.melt_quote(request, cdk_options).await?;
+        let quote = self
+            .inner
+            .melt_quote(cdk::nuts::PaymentMethod::BOLT11, request, cdk_options, None)
+            .await?;
         Ok(quote.into())
     }
 
-    /// Melt tokens
-    pub async fn melt(&self, quote_id: String) -> Result<Melted, FfiError> {
-        let melted = self.inner.melt(&quote_id).await?;
-        Ok(melted.into())
+    /// Prepare a melt operation
+    ///
+    /// Returns a `PreparedMelt` that can be confirmed or cancelled.
+    pub async fn prepare_melt(&self, quote_id: String) -> Result<PreparedMelt, FfiError> {
+        let prepared = self
+            .inner
+            .prepare_melt(&quote_id, std::collections::HashMap::new())
+            .await?;
+        Ok(PreparedMelt::new(Arc::clone(&self.inner), &prepared))
     }
 
-    /// Melt specific proofs
+    /// Prepare a melt operation with specific proofs
     ///
     /// This method allows melting proofs that may not be in the wallet's database,
     /// similar to how `receive_proofs` handles external proofs. The proofs will be
@@ -247,28 +288,23 @@ impl Wallet {
     ///
     /// # Returns
     ///
-    /// A `Melted` result containing the payment details and any change proofs
-    pub async fn melt_proofs(&self, quote_id: String, proofs: Proofs) -> Result<Melted, FfiError> {
+    /// A `PreparedMelt` that can be confirmed or cancelled
+    pub async fn prepare_melt_proofs(
+        &self,
+        quote_id: String,
+        proofs: Proofs,
+    ) -> Result<PreparedMelt, FfiError> {
         let cdk_proofs: Result<Vec<cdk::nuts::Proof>, _> =
             proofs.into_iter().map(|p| p.try_into()).collect();
         let cdk_proofs = cdk_proofs?;
 
-        let melted = self.inner.melt_proofs(&quote_id, cdk_proofs).await?;
-        Ok(melted.into())
-    }
-
-    /// Get a quote for a bolt12 mint
-    pub async fn mint_bolt12_quote(
-        &self,
-        amount: Option<Amount>,
-        description: Option<String>,
-    ) -> Result<MintQuote, FfiError> {
-        let quote = self
+        let prepared = self
             .inner
-            .mint_bolt12_quote(amount.map(Into::into), description)
+            .prepare_melt_proofs(&quote_id, cdk_proofs, std::collections::HashMap::new())
             .await?;
-        Ok(quote.into())
+        Ok(PreparedMelt::new(Arc::clone(&self.inner), &prepared))
     }
+
     /// Get a mint quote using a unified interface for any payment method
     ///
     /// This method supports bolt11, bolt12, and custom payment methods.
@@ -287,39 +323,18 @@ impl Wallet {
         description: Option<String>,
         extra: Option<String>,
     ) -> Result<MintQuote, FfiError> {
+        let method: cdk::nuts::PaymentMethod = method.into();
+
         let quote = self
             .inner
-            .mint_quote_unified(amount.map(Into::into), method.into(), description, extra)
+            .mint_quote(method, amount.map(Into::into), description, extra)
             .await?;
         Ok(quote.into())
     }
 
-    /// Mint tokens using bolt12
-    pub async fn mint_bolt12(
-        &self,
-        quote_id: String,
-        amount: Option<Amount>,
-        amount_split_target: SplitTarget,
-        spending_conditions: Option<SpendingConditions>,
-    ) -> Result<Proofs, FfiError> {
-        let conditions = spending_conditions.map(|sc| sc.try_into()).transpose()?;
-
-        let proofs = self
-            .inner
-            .mint_bolt12(
-                &quote_id,
-                amount.map(Into::into),
-                amount_split_target.into(),
-                conditions,
-            )
-            .await?;
-
-        Ok(proofs.into_iter().map(|p| p.into()).collect())
-    }
     pub async fn mint_unified(
         &self,
         quote_id: String,
-        amount: Option<Amount>,
         amount_split_target: SplitTarget,
         spending_conditions: Option<SpendingConditions>,
     ) -> Result<Proofs, FfiError> {
@@ -327,12 +342,7 @@ impl Wallet {
 
         let proofs = self
             .inner
-            .mint_unified(
-                &quote_id,
-                amount.map(Into::into),
-                amount_split_target.into(),
-                conditions,
-            )
+            .mint(&quote_id, amount_split_target.into(), conditions)
             .await?;
 
         Ok(proofs.into_iter().map(|p| p.into()).collect())
@@ -344,7 +354,10 @@ impl Wallet {
         options: Option<MeltOptions>,
     ) -> Result<MeltQuote, FfiError> {
         let cdk_options = options.map(Into::into);
-        let quote = self.inner.melt_bolt12_quote(request, cdk_options).await?;
+        let quote = self
+            .inner
+            .melt_quote(cdk::nuts::PaymentMethod::BOLT12, request, cdk_options, None)
+            .await?;
         Ok(quote.into())
     }
     /// Get a melt quote using a unified interface for any payment method
@@ -358,23 +371,17 @@ impl Wallet {
     /// * `request` - Payment request string (invoice, offer, or custom format)
     /// * `options` - Optional melt options (MPP, amountless, etc.)
     /// * `extra` - Optional JSON string with extra payment-method-specific fields (for custom methods)
-    pub async fn melt_quote_unified(
+    pub async fn melt_quote(
         &self,
         method: PaymentMethod,
         request: String,
         options: Option<MeltOptions>,
         extra: Option<String>,
     ) -> Result<MeltQuote, FfiError> {
-        // Parse the extra JSON string into a serde_json::Value
-        let extra_value = extra
-            .map(|s| serde_json::from_str(&s))
-            .transpose()
-            .map_err(|e| FfiError::internal(format!("Invalid extra JSON: {}", e)))?;
-
         let cdk_options = options.map(Into::into);
         let quote = self
             .inner
-            .melt_quote_unified(method.into(), request, cdk_options, extra_value)
+            .melt_quote::<cdk::nuts::PaymentMethod, _>(method.into(), request, cdk_options, extra)
             .await?;
         Ok(quote.into())
     }
@@ -528,16 +535,10 @@ impl Wallet {
             .fee())
     }
 
-    /// Reclaim unspent proofs (mark them as unspent in the database)
-    pub async fn reclaim_unspent(&self, proofs: Proofs) -> Result<(), FfiError> {
-        let cdk_proofs: Result<Vec<cdk::nuts::Proof>, _> =
-            proofs.iter().map(|p| p.clone().try_into()).collect();
-        let cdk_proofs = cdk_proofs?;
-        self.inner.reclaim_unspent(cdk_proofs).await?;
-        Ok(())
-    }
-
-    /// Check all pending proofs and return the total amount reclaimed
+    /// Check all pending proofs and return the total amount still pending
+    ///
+    /// This function checks orphaned pending proofs (not managed by active sagas)
+    /// with the mint and marks spent proofs accordingly.
     pub async fn check_all_pending_proofs(&self) -> Result<Amount, FfiError> {
         let amount = self.inner.check_all_pending_proofs().await?;
         Ok(amount.into())

+ 3 - 2
crates/cdk-integration-tests/src/cli.rs

@@ -28,11 +28,12 @@ pub fn init_logging(enable_logging: bool, log_level: tracing::Level) {
         let h2_filter = "h2=warn";
         let rustls_filter = "rustls=warn";
         let reqwest_filter = "reqwest=warn";
-        let tower_filter = "tower_http=warn";
+        let tower_http_filter = "tower_http=warn";
+        let tower_filter = "tower=warn";
         let tokio_postgres_filter = "tokio_postgres=warn";
 
         let env_filter = EnvFilter::new(format!(
-            "{default_filter},{hyper_filter},{h2_filter},{rustls_filter},{reqwest_filter},{tower_filter},{tokio_postgres_filter}"
+            "{default_filter},{hyper_filter},{h2_filter},{rustls_filter},{reqwest_filter},{tower_http_filter},{tower_filter},{tokio_postgres_filter}"
         ));
 
         // Ok if successful, Err if already initialized

+ 5 - 2
crates/cdk-integration-tests/src/init_pure_tests.rs

@@ -267,10 +267,11 @@ pub fn setup_tracing() {
 
     let h2_filter = "h2=warn";
     let hyper_filter = "hyper=warn";
+    let tower_filter = "tower=warn";
     let tokio_postgres = "tokio_postgres=warn";
 
     let env_filter = EnvFilter::new(format!(
-        "{default_filter},{h2_filter},{hyper_filter},{tokio_postgres}"
+        "{default_filter},{h2_filter},{hyper_filter},{tower_filter},{tokio_postgres}"
     ));
 
     // Ok if successful, Err if already initialized
@@ -414,7 +415,9 @@ pub async fn fund_wallet(
     split_target: Option<SplitTarget>,
 ) -> Result<Amount> {
     let desired_amount = Amount::from(amount);
-    let quote = wallet.mint_quote(desired_amount, None).await?;
+    let quote = wallet
+        .mint_quote(PaymentMethod::BOLT11, Some(desired_amount), None, None)
+        .await?;
 
     Ok(wallet
         .proof_stream(quote, split_target.unwrap_or_default(), None)

+ 57 - 2
crates/cdk-integration-tests/src/lib.rs

@@ -21,8 +21,12 @@ use std::path::{Path, PathBuf};
 use std::sync::Arc;
 
 use anyhow::{anyhow, bail, Result};
-use cashu::Bolt11Invoice;
+use cashu::{Bolt11Invoice, PaymentMethod};
 use cdk::amount::{Amount, SplitTarget};
+use cdk::nuts::{
+    MeltQuoteBolt11Response, MeltRequest, MintRequest, MintResponse, PreMintSecrets, Proofs,
+};
+use cdk::wallet::{HttpClient, MintConnector, MintQuote};
 use cdk::{StreamExt, Wallet};
 use cdk_fake_wallet::create_fake_invoice;
 use init_regtest::{get_lnd_dir, LND_RPC_ADDR};
@@ -49,7 +53,7 @@ pub fn standard_keyset_amounts(max_order: u32) -> Vec<u64> {
 
 pub async fn fund_wallet(wallet: Arc<Wallet>, amount: Amount) {
     let quote = wallet
-        .mint_quote(amount, None)
+        .mint_quote(PaymentMethod::BOLT11, Some(amount), None, None)
         .await
         .expect("Could not get mint quote");
 
@@ -220,3 +224,54 @@ async fn create_cln_client_with_retry() -> ClnClient {
         }
     }
 }
+
+pub async fn attempt_manual_mint(
+    wallet: &Wallet,
+    mint_url: &str,
+    mint_quote: &MintQuote,
+    mint_amount: Amount,
+    payment_method: PaymentMethod,
+) -> Result<MintResponse, cdk::Error> {
+    let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id;
+    let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
+    let http_client = HttpClient::new(mint_url.parse().unwrap(), None);
+
+    let premint_secrets = PreMintSecrets::random(
+        active_keyset_id,
+        mint_amount,
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .unwrap();
+
+    let mut request = MintRequest {
+        quote: mint_quote.id.clone(),
+        outputs: premint_secrets.blinded_messages(),
+        signature: None,
+    };
+
+    request
+        .sign(
+            mint_quote
+                .secret_key
+                .as_ref()
+                .expect("Secret key on quote")
+                .clone(),
+        )
+        .unwrap();
+
+    http_client.post_mint(&payment_method, request).await
+}
+
+pub async fn attempt_manual_melt(
+    mint_url: &str,
+    quote_id: String,
+    inputs: Proofs,
+    payment_method: PaymentMethod,
+) -> Result<MeltQuoteBolt11Response<String>, cdk::Error> {
+    let http_client = HttpClient::new(mint_url.parse().unwrap(), None);
+
+    let request = MeltRequest::new(quote_id, inputs, None);
+
+    http_client.post_melt(&payment_method, request).await
+}

+ 30 - 13
crates/cdk-integration-tests/tests/async_melt.rs

@@ -11,6 +11,7 @@
 use std::sync::Arc;
 
 use bip39::Mnemonic;
+use cashu::PaymentMethod;
 use cdk::amount::SplitTarget;
 use cdk::nuts::{CurrencyUnit, MeltQuoteState};
 use cdk::wallet::Wallet;
@@ -36,7 +37,7 @@ async fn test_async_melt_returns_pending() {
     .expect("failed to create new wallet");
 
     // Step 1: Mint some tokens
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
     let _proofs = proof_streams
@@ -56,19 +57,25 @@ async fn test_async_melt_returns_pending() {
         check_err: false,
     };
 
-    let invoice = create_fake_invoice(
+    let invoice: cashu::Bolt11Invoice = create_fake_invoice(
         50_000, // 50 sats in millisats
         serde_json::to_string(&fake_invoice_description).unwrap(),
     );
 
-    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     // Step 3: Call melt (wallet handles proof selection internally)
     let start_time = std::time::Instant::now();
 
     // This should complete and return the final state
-    // TODO: Add Prefer: respond-async header support to wallet.melt()
-    let melt_response = wallet.melt(&melt_quote.id).await.unwrap();
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+    let confirmed = prepared.confirm().await.unwrap();
 
     let elapsed = start_time.elapsed();
 
@@ -77,7 +84,7 @@ async fn test_async_melt_returns_pending() {
 
     // Step 4: Verify the melt completed successfully
     assert_eq!(
-        melt_response.state,
+        confirmed.state(),
         MeltQuoteState::Paid,
         "Melt should complete with PAID state"
     );
@@ -99,7 +106,7 @@ async fn test_sync_melt_completes_fully() {
     .expect("failed to create new wallet");
 
     // Step 1: Mint some tokens
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
     let _proofs = proof_streams
@@ -124,20 +131,30 @@ async fn test_sync_melt_completes_fully() {
         serde_json::to_string(&fake_invoice_description).unwrap(),
     );
 
-    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
-    // Step 3: Call synchronous melt
-    let melt_response = wallet.melt(&melt_quote.id).await.unwrap();
+    // Step 3: Call melt with prepare/confirm pattern
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+    let confirmed = prepared.confirm().await.unwrap();
 
     // Step 5: Verify response shows payment completed
     assert_eq!(
-        melt_response.state,
+        confirmed.state(),
         MeltQuoteState::Paid,
-        "Synchronous melt should return PAID state"
+        "Melt should return PAID state"
     );
 
     // Step 6: Verify the quote is PAID in the mint
-    let quote_state = wallet.melt_quote_status(&melt_quote.id).await.unwrap();
+    let quote_state = wallet
+        .check_melt_quote_status(&melt_quote.id)
+        .await
+        .unwrap();
     assert_eq!(
         quote_state.state,
         MeltQuoteState::Paid,

+ 89 - 33
crates/cdk-integration-tests/tests/bolt12.rs

@@ -70,7 +70,7 @@ async fn test_regtest_bolt12_mint() {
     let mint_amount = Amount::from(100);
 
     let mint_quote = wallet
-        .mint_bolt12_quote(Some(mint_amount), None)
+        .mint_quote(PaymentMethod::BOLT12, Some(mint_amount), None, None)
         .await
         .unwrap();
 
@@ -117,7 +117,9 @@ async fn test_regtest_bolt12_mint_multiple() -> Result<()> {
         .use_http_subscription()
         .build()?;
 
-    let mint_quote = wallet.mint_bolt12_quote(None, None).await?;
+    let mint_quote = wallet
+        .mint_quote(PaymentMethod::BOLT12, None, None, None)
+        .await?;
 
     let work_dir = get_test_temp_dir();
     let cln_one_dir = get_cln_dir(&work_dir, "one");
@@ -188,7 +190,7 @@ async fn test_regtest_bolt12_multiple_wallets() -> Result<()> {
     let cln_client = create_cln_client_with_retry(cln_one_dir.clone()).await?;
     // First wallet payment
     let quote_one = wallet_one
-        .mint_bolt12_quote(Some(10_000.into()), None)
+        .mint_quote(PaymentMethod::BOLT12, Some(10_000.into()), None, None)
         .await?;
     cln_client
         .pay_bolt12_offer(None, quote_one.request.clone())
@@ -207,7 +209,7 @@ async fn test_regtest_bolt12_multiple_wallets() -> Result<()> {
 
     // Second wallet payment
     let quote_two = wallet_two
-        .mint_bolt12_quote(Some(15_000.into()), None)
+        .mint_quote(PaymentMethod::BOLT12, Some(15_000.into()), None, None)
         .await?;
     cln_client
         .pay_bolt12_offer(None, quote_two.request.clone())
@@ -229,34 +231,44 @@ async fn test_regtest_bolt12_multiple_wallets() -> Result<()> {
         .await?;
 
     let wallet_one_melt_quote = wallet_one
-        .melt_bolt12_quote(
+        .melt_quote(
+            PaymentMethod::BOLT12,
             offer.to_string(),
             Some(cashu::MeltOptions::Amountless {
                 amountless: Amountless {
                     amount_msat: 1500.into(),
                 },
             }),
+            None,
         )
         .await?;
 
     let wallet_two_melt_quote = wallet_two
-        .melt_bolt12_quote(
+        .melt_quote(
+            PaymentMethod::BOLT12,
             offer.to_string(),
             Some(cashu::MeltOptions::Amountless {
                 amountless: Amountless {
                     amount_msat: 1000.into(),
                 },
             }),
+            None,
         )
         .await?;
 
-    let melted = wallet_one.melt(&wallet_one_melt_quote.id).await?;
+    let prepared_one = wallet_one
+        .prepare_melt(&wallet_one_melt_quote.id, std::collections::HashMap::new())
+        .await?;
+    let melted = prepared_one.confirm().await?;
 
-    assert!(melted.preimage.is_some());
+    assert!(melted.payment_proof().is_some());
 
-    let melted_two = wallet_two.melt(&wallet_two_melt_quote.id).await?;
+    let prepared_two = wallet_two
+        .prepare_melt(&wallet_two_melt_quote.id, std::collections::HashMap::new())
+        .await?;
+    let melted_two = prepared_two.confirm().await?;
 
-    assert!(melted_two.preimage.is_some());
+    assert!(melted_two.payment_proof().is_some());
 
     Ok(())
 }
@@ -279,7 +291,9 @@ async fn test_regtest_bolt12_melt() -> Result<()> {
     let mint_amount = Amount::from(20_000);
 
     // Create a single-use BOLT12 quote
-    let mint_quote = wallet.mint_bolt12_quote(Some(mint_amount), None).await?;
+    let mint_quote = wallet
+        .mint_quote(PaymentMethod::BOLT12, Some(mint_amount), None, None)
+        .await?;
 
     assert_eq!(mint_quote.amount, Some(mint_amount));
     // Pay the quote
@@ -303,11 +317,16 @@ async fn test_regtest_bolt12_melt() -> Result<()> {
         .get_bolt12_offer(Some(10_000), true, "hhhhhhhh".to_string())
         .await?;
 
-    let quote = wallet.melt_bolt12_quote(offer.to_string(), None).await?;
+    let quote = wallet
+        .melt_quote(PaymentMethod::BOLT12, offer.to_string(), None, None)
+        .await?;
 
-    let melt = wallet.melt(&quote.id).await?;
+    let prepared = wallet
+        .prepare_melt(&quote.id, std::collections::HashMap::new())
+        .await?;
+    let melt = prepared.confirm().await?;
 
-    assert_eq!(melt.amount, 10.into());
+    assert_eq!(melt.amount(), 10.into());
 
     Ok(())
 }
@@ -331,9 +350,11 @@ async fn test_regtest_bolt12_mint_extra() -> Result<()> {
     )?;
 
     // Create a single-use BOLT12 quote
-    let mint_quote = wallet.mint_bolt12_quote(None, None).await?;
+    let mint_quote = wallet
+        .mint_quote(PaymentMethod::BOLT12, None, None, None)
+        .await?;
 
-    let state = wallet.mint_bolt12_quote_state(&mint_quote.id).await?;
+    let state = wallet.refresh_mint_quote_status(&mint_quote.id).await?;
 
     assert_eq!(state.amount_paid, Amount::ZERO);
     assert_eq!(state.amount_issued, Amount::ZERO);
@@ -354,7 +375,7 @@ async fn test_regtest_bolt12_mint_extra() -> Result<()> {
         .await?
         .unwrap();
 
-    let state = wallet.mint_bolt12_quote_state(&mint_quote.id).await?;
+    let state = wallet.refresh_mint_quote_status(&mint_quote.id).await?;
 
     assert_eq!(payment, state.amount_paid);
     assert_eq!(state.amount_paid, (pay_amount_msats / 1_000).into());
@@ -423,14 +444,30 @@ async fn test_attempt_to_mint_unpaid() {
     let mint_amount = Amount::from(100);
 
     let mint_quote = wallet
-        .mint_bolt12_quote(Some(mint_amount), None)
+        .mint_quote(PaymentMethod::BOLT12, Some(mint_amount), None, None)
         .await
         .unwrap();
 
     assert_eq!(mint_quote.amount, Some(mint_amount));
 
+    let mut mint_quote = wallet
+        .localstore
+        .get_mint_quote(&mint_quote.id)
+        .await
+        .unwrap()
+        .unwrap();
+    // Since the wallet checks how much it can mint
+    // we manually set it in the db to fake like it was paid to the wallet
+    // so it tries to mint
+    mint_quote.amount_paid = mint_amount;
+    wallet
+        .localstore
+        .add_mint_quote(mint_quote.clone())
+        .await
+        .unwrap();
+
     let proofs = wallet
-        .mint_bolt12(&mint_quote.id, None, SplitTarget::default(), None)
+        .mint(&mint_quote.id, SplitTarget::default(), None)
         .await;
 
     match proofs {
@@ -445,19 +482,34 @@ async fn test_attempt_to_mint_unpaid() {
     }
 
     let mint_quote = wallet
-        .mint_bolt12_quote(Some(mint_amount), None)
+        .mint_quote(PaymentMethod::BOLT12, Some(mint_amount), None, None)
         .await
         .unwrap();
 
     let state = wallet
-        .mint_bolt12_quote_state(&mint_quote.id)
+        .refresh_mint_quote_status(&mint_quote.id)
         .await
         .unwrap();
 
     assert!(state.amount_paid == Amount::ZERO);
+    let mut mint_quote = wallet
+        .localstore
+        .get_mint_quote(&mint_quote.id)
+        .await
+        .unwrap()
+        .unwrap();
+    // Since the wallet checks how much it can mint
+    // we manually set it in the db to fake like it was paid to the wallet
+    // so it tries to mint
+    mint_quote.amount_paid = mint_amount;
+    wallet
+        .localstore
+        .add_mint_quote(mint_quote.clone())
+        .await
+        .unwrap();
 
     let proofs = wallet
-        .mint_bolt12(&mint_quote.id, None, SplitTarget::default(), None)
+        .mint(&mint_quote.id, SplitTarget::default(), None)
         .await;
 
     match proofs {
@@ -491,7 +543,9 @@ async fn test_check_all_mint_quotes_bolt12() -> Result<()> {
     let mint_amount = Amount::from(100);
 
     // Create a Bolt12 quote
-    let mint_quote = wallet.mint_bolt12_quote(Some(mint_amount), None).await?;
+    let mint_quote = wallet
+        .mint_quote(PaymentMethod::BOLT12, Some(mint_amount), None, None)
+        .await?;
 
     assert_eq!(mint_quote.amount, Some(mint_amount));
 
@@ -518,20 +572,20 @@ async fn test_check_all_mint_quotes_bolt12() -> Result<()> {
     // Verify initial balance is zero
     assert_eq!(wallet.total_balance().await?, Amount::ZERO);
 
-    // Call check_all_mint_quotes - this should mint the paid Bolt12 quote
-    let total_minted = wallet.check_all_mint_quotes().await?;
+    // Call mint_unissued_quotes - this should mint the paid Bolt12 quote
+    let total_minted = wallet.mint_unissued_quotes().await?;
 
     // Verify the amount minted is correct
     assert_eq!(
         total_minted, mint_amount,
-        "check_all_mint_quotes should have minted the Bolt12 quote"
+        "mint_unissued_quotes should have minted the Bolt12 quote"
     );
 
     // Verify wallet balance matches
     assert_eq!(wallet.total_balance().await?, mint_amount);
 
-    // Calling check_all_mint_quotes again should return 0 (quote already fully issued)
-    let second_check = wallet.check_all_mint_quotes().await?;
+    // Calling mint_unissued_quotes again should return 0 (quote already fully issued)
+    let second_check = wallet.mint_unissued_quotes().await?;
     assert_eq!(
         second_check,
         Amount::ZERO,
@@ -558,10 +612,12 @@ async fn test_bolt12_quote_amount_issued_tracking() -> Result<()> {
     )?;
 
     // Create an open-ended Bolt12 quote (no amount specified)
-    let mint_quote = wallet.mint_bolt12_quote(None, None).await?;
+    let mint_quote = wallet
+        .mint_quote(PaymentMethod::BOLT12, None, None, None)
+        .await?;
 
     // Verify initial state
-    let state_before = wallet.mint_bolt12_quote_state(&mint_quote.id).await?;
+    let state_before = wallet.refresh_mint_quote_status(&mint_quote.id).await?;
     assert_eq!(state_before.amount_paid, Amount::ZERO);
     assert_eq!(state_before.amount_issued, Amount::ZERO);
 
@@ -581,7 +637,7 @@ async fn test_bolt12_quote_amount_issued_tracking() -> Result<()> {
         .expect("Should receive payment notification");
 
     // Check state after payment but before minting
-    let state_after_payment = wallet.mint_bolt12_quote_state(&mint_quote.id).await?;
+    let state_after_payment = wallet.refresh_mint_quote_status(&mint_quote.id).await?;
     assert_eq!(
         state_after_payment.amount_paid,
         Amount::from(pay_amount_msats / 1000)
@@ -594,14 +650,14 @@ async fn test_bolt12_quote_amount_issued_tracking() -> Result<()> {
 
     // Now mint the tokens
     let proofs = wallet
-        .mint_bolt12(&mint_quote.id, None, SplitTarget::default(), None)
+        .mint(&mint_quote.id, SplitTarget::default(), None)
         .await?;
 
     let minted_amount = proofs.total_amount()?;
     assert_eq!(minted_amount, payment);
 
     // Check state after minting
-    let state_after_mint = wallet.mint_bolt12_quote_state(&mint_quote.id).await?;
+    let state_after_mint = wallet.refresh_mint_quote_status(&mint_quote.id).await?;
     assert_eq!(
         state_after_mint.amount_issued, minted_amount,
         "amount_issued should be updated after minting"

+ 12 - 8
crates/cdk-integration-tests/tests/fake_auth.rs

@@ -333,7 +333,7 @@ async fn test_mint_with_auth() {
 
     let mint_amount: Amount = 100.into();
 
-    let quote = wallet.mint_quote(mint_amount, None).await.unwrap();
+    let quote = wallet.mint_bolt11_quote(mint_amount, None).await.unwrap();
 
     let proofs = wallet
         .wait_and_mint_quote(
@@ -432,13 +432,17 @@ async fn test_melt_with_auth() {
     let bolt11 = create_fake_invoice(2_000, "".to_string());
 
     let melt_quote = wallet
-        .melt_quote(bolt11.to_string(), None)
+        .melt_quote(PaymentMethod::BOLT11, bolt11.to_string(), None, None)
         .await
         .expect("Could not get melt quote");
 
-    let after_melt = wallet.melt(&melt_quote.id).await.expect("Could not melt");
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .expect("Could not prepare melt");
+    let after_melt = prepared.confirm().await.expect("Could not melt");
 
-    assert!(after_melt.state == MeltQuoteState::Paid);
+    assert!(after_melt.state() == MeltQuoteState::Paid);
 }
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
@@ -512,7 +516,7 @@ async fn test_reuse_auth_proof() {
 
     {
         let quote = wallet
-            .mint_quote(10.into(), None)
+            .mint_bolt11_quote(10.into(), None)
             .await
             .expect("Quote should be allowed");
 
@@ -526,7 +530,7 @@ async fn test_reuse_auth_proof() {
         .unwrap();
 
     {
-        let quote_res = wallet.mint_quote(10.into(), None).await;
+        let quote_res = wallet.mint_bolt11_quote(10.into(), None).await;
         assert!(
             matches!(quote_res, Err(Error::TokenAlreadySpent)),
             "Expected AuthRequired error, got {:?}",
@@ -643,7 +647,7 @@ async fn test_refresh_access_token() {
 
     // Try to get a mint quote with the refreshed token
     let mint_quote = wallet
-        .mint_quote(mint_amount, None)
+        .mint_bolt11_quote(mint_amount, None)
         .await
         .expect("failed to get mint quote with refreshed token");
 
@@ -729,7 +733,7 @@ async fn test_auth_token_spending_order() {
     // Use tokens and verify they're used in the expected order (FIFO)
     for i in 0..3 {
         let mint_quote = wallet
-            .mint_quote(10.into(), None)
+            .mint_bolt11_quote(10.into(), None)
             .await
             .expect("failed to get mint quote");
 

+ 239 - 95
crates/cdk-integration-tests/tests/fake_wallet.rs

@@ -45,7 +45,7 @@ async fn test_fake_tokens_pending() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
@@ -64,9 +64,18 @@ async fn test_fake_tokens_pending() {
 
     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();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
-    let melt = wallet.melt(&melt_quote.id).await;
+    let melt = async {
+        let prepared = wallet
+            .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+            .await?;
+        prepared.confirm().await
+    }
+    .await;
 
     assert!(melt.is_err());
 
@@ -92,7 +101,7 @@ async fn test_fake_melt_payment_fail() {
     )
     .expect("Failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
@@ -111,10 +120,19 @@ async fn test_fake_melt_payment_fail() {
 
     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();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     // The melt should error at the payment invoice command
-    let melt = wallet.melt(&melt_quote.id).await;
+    let melt = async {
+        let prepared = wallet
+            .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+            .await?;
+        prepared.confirm().await
+    }
+    .await;
     assert!(melt.is_err());
 
     let fake_description = FakeInvoiceDescription {
@@ -126,10 +144,19 @@ async fn test_fake_melt_payment_fail() {
 
     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();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     // The melt should error at the payment invoice command
-    let melt = wallet.melt(&melt_quote.id).await;
+    let melt = async {
+        let prepared = wallet
+            .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+            .await?;
+        prepared.confirm().await
+    }
+    .await;
     assert!(melt.is_err());
 
     let wallet_bal = wallet.total_balance().await.unwrap();
@@ -149,7 +176,7 @@ async fn test_fake_melt_payment_fail_and_check() {
     )
     .expect("Failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
@@ -168,10 +195,19 @@ async fn test_fake_melt_payment_fail_and_check() {
 
     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();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     // The melt should error at the payment invoice command
-    let melt = wallet.melt(&melt_quote.id).await;
+    let melt = async {
+        let prepared = wallet
+            .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+            .await?;
+        prepared.confirm().await
+    }
+    .await;
     assert!(melt.is_err());
 
     assert!(!wallet
@@ -195,7 +231,7 @@ async fn test_fake_melt_payment_return_fail_status() {
     )
     .expect("Failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
@@ -214,10 +250,19 @@ async fn test_fake_melt_payment_return_fail_status() {
 
     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();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     // The melt should error at the payment invoice command
-    let melt = wallet.melt(&melt_quote.id).await;
+    let melt = async {
+        let prepared = wallet
+            .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+            .await?;
+        prepared.confirm().await
+    }
+    .await;
     assert!(melt.is_err());
 
     wallet.check_all_pending_proofs().await.unwrap();
@@ -239,10 +284,19 @@ async fn test_fake_melt_payment_return_fail_status() {
 
     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();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     // The melt should error at the payment invoice command
-    let melt = wallet.melt(&melt_quote.id).await;
+    let melt = async {
+        let prepared = wallet
+            .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+            .await?;
+        prepared.confirm().await
+    }
+    .await;
     assert!(melt.is_err());
 
     wallet.check_all_pending_proofs().await.unwrap();
@@ -268,7 +322,7 @@ async fn test_fake_melt_payment_error_unknown() {
     )
     .unwrap();
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
@@ -287,10 +341,19 @@ async fn test_fake_melt_payment_error_unknown() {
 
     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();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     // The melt should error at the payment invoice command
-    let melt = wallet.melt(&melt_quote.id).await;
+    let melt = async {
+        let prepared = wallet
+            .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+            .await?;
+        prepared.confirm().await
+    }
+    .await;
     assert!(melt.is_err());
 
     let fake_description = FakeInvoiceDescription {
@@ -302,10 +365,19 @@ async fn test_fake_melt_payment_error_unknown() {
 
     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();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     // The melt should error at the payment invoice command
-    let melt = wallet.melt(&melt_quote.id).await;
+    let melt = async {
+        let prepared = wallet
+            .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+            .await?;
+        prepared.confirm().await
+    }
+    .await;
     assert!(melt.is_err());
 
     assert!(wallet
@@ -329,7 +401,7 @@ async fn test_fake_melt_payment_err_paid() {
     )
     .expect("Failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
@@ -350,17 +422,24 @@ async fn test_fake_melt_payment_err_paid() {
 
     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();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
-    // The melt should error at the payment invoice command
-    let melt = wallet.melt(&melt_quote.id).await.unwrap();
+    // The melt should complete successfully
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+    let melt = prepared.confirm().await.unwrap();
 
-    assert!(melt.fee_paid == Amount::ZERO);
-    assert!(melt.amount == Amount::from(7));
+    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,
+        old_balance - melt.amount(),
         wallet.total_balance().await.expect("new balance")
     );
 
@@ -384,7 +463,7 @@ async fn test_fake_melt_change_in_quote() {
     )
     .expect("Failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
@@ -412,7 +491,10 @@ async fn test_fake_melt_change_in_quote() {
 
     let proofs = wallet.get_unspent_proofs().await.unwrap();
 
-    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     let keyset = wallet.fetch_active_keyset().await.unwrap();
     let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
@@ -440,7 +522,7 @@ async fn test_fake_melt_change_in_quote() {
 
     assert!(melt_response.change.is_some());
 
-    let check = wallet.melt_quote_status(&melt_quote.id).await.unwrap();
+    let check = client.get_melt_quote_status(&melt_quote.id).await.unwrap();
     let mut melt_change = melt_response.change.unwrap();
     melt_change.sort_by(|a, b| a.amount.cmp(&b.amount));
 
@@ -461,7 +543,7 @@ async fn test_fake_mint_with_witness() {
         None,
     )
     .expect("failed to create new wallet");
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
@@ -488,7 +570,7 @@ async fn test_fake_mint_without_witness() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut payment_streams = wallet.payment_stream(&mint_quote);
 
@@ -540,7 +622,7 @@ async fn test_fake_mint_with_wrong_witness() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut payment_streams = wallet.payment_stream(&mint_quote);
 
@@ -598,7 +680,7 @@ async fn test_fake_mint_inflated() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut payment_streams = wallet.payment_stream(&mint_quote);
 
@@ -671,7 +753,7 @@ async fn test_fake_mint_multiple_units() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut payment_streams = wallet.payment_stream(&mint_quote);
 
@@ -771,7 +853,7 @@ async fn test_fake_mint_multiple_unit_swap() {
 
     wallet.refresh_keysets().await.unwrap();
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
@@ -791,7 +873,10 @@ async fn test_fake_mint_multiple_unit_swap() {
     .expect("failed to create usd wallet");
     wallet_usd.refresh_keysets().await.unwrap();
 
-    let mint_quote = wallet_usd.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet_usd
+        .mint_bolt11_quote(100.into(), None)
+        .await
+        .unwrap();
 
     let mut proof_streams =
         wallet_usd.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
@@ -896,7 +981,7 @@ async fn test_fake_mint_multiple_unit_melt() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
@@ -917,7 +1002,10 @@ async fn test_fake_mint_multiple_unit_melt() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet_usd.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet_usd
+        .mint_bolt11_quote(100.into(), None)
+        .await
+        .unwrap();
     println!("Minted quote usd");
 
     let mut proof_streams =
@@ -940,7 +1028,10 @@ async fn test_fake_mint_multiple_unit_melt() {
 
         let input_amount: u64 = inputs.total_amount().unwrap().into();
         let invoice = create_fake_invoice((input_amount - 1) * 1000, "".to_string());
-        let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+        let melt_quote = wallet
+            .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+            .await
+            .unwrap();
 
         let melt_request = MeltRequest::new(melt_quote.id, inputs, None);
 
@@ -994,7 +1085,10 @@ async fn test_fake_mint_multiple_unit_melt() {
         let mut sat_outputs = pre_mint.blinded_messages();
 
         usd_outputs.append(&mut sat_outputs);
-        let quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+        let quote = wallet
+            .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+            .await
+            .unwrap();
 
         let melt_request = MeltRequest::new(quote.id, inputs, Some(usd_outputs));
 
@@ -1033,7 +1127,7 @@ async fn test_fake_mint_input_output_mismatch() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
@@ -1092,7 +1186,7 @@ async fn test_fake_mint_swap_inflated() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
     let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
@@ -1142,7 +1236,7 @@ async fn test_fake_mint_swap_spend_after_fail() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
@@ -1229,7 +1323,7 @@ async fn test_fake_mint_melt_spend_after_fail() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
@@ -1280,7 +1374,10 @@ async fn test_fake_mint_melt_spend_after_fail() {
 
     let input_amount: u64 = proofs.total_amount().unwrap().into();
     let invoice = create_fake_invoice((input_amount - 1) * 1000, "".to_string());
-    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     let melt_request = MeltRequest::new(melt_quote.id, proofs, None);
 
@@ -1317,7 +1414,7 @@ async fn test_fake_mint_duplicate_proofs_swap() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
@@ -1398,7 +1495,7 @@ async fn test_fake_mint_duplicate_proofs_melt() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
@@ -1412,7 +1509,10 @@ async fn test_fake_mint_duplicate_proofs_melt() {
 
     let invoice = create_fake_invoice(7000, "".to_string());
 
-    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     let melt_request = MeltRequest::new(melt_quote.id, inputs, None);
 
@@ -1451,7 +1551,7 @@ async fn test_wallet_proof_recovery_after_failed_melt() {
     .expect("failed to create new wallet");
 
     // Mint 100 sats
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
     let _roof_streams = wallet
         .wait_and_mint_quote(
             mint_quote.clone(),
@@ -1472,10 +1572,19 @@ async fn test_wallet_proof_recovery_after_failed_melt() {
     };
 
     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();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     // Attempt to melt - this should fail but trigger proof recovery
-    let melt_result = wallet.melt(&melt_quote.id).await;
+    let melt_result = async {
+        let prepared = wallet
+            .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+            .await?;
+        prepared.confirm().await
+    }
+    .await;
     assert!(melt_result.is_err(), "Melt should have failed");
 
     // Verify wallet still has balance (proofs recovered)
@@ -1488,11 +1597,17 @@ async fn test_wallet_proof_recovery_after_failed_melt() {
     // 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)
+        .melt_quote(PaymentMethod::BOLT11, valid_invoice.to_string(), None, None)
         .await
         .unwrap();
 
-    let successful_melt = wallet.melt(&valid_melt_quote.id).await;
+    let successful_melt = async {
+        let prepared = wallet
+            .prepare_melt(&valid_melt_quote.id, std::collections::HashMap::new())
+            .await?;
+        prepared.confirm().await
+    }
+    .await;
     assert!(
         successful_melt.is_ok(),
         "Should be able to spend recovered proofs"
@@ -1526,7 +1641,7 @@ async fn test_concurrent_melt_same_invoice() {
 
     // Mint proofs for all wallets
     for (i, wallet) in wallets.iter().enumerate() {
-        let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+        let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
         let mut proof_streams =
             wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
         proof_streams
@@ -1543,7 +1658,10 @@ async fn test_concurrent_melt_same_invoice() {
     // All wallets create melt quotes for the same invoice
     let mut melt_quotes = Vec::with_capacity(NUM_WALLETS);
     for wallet in &wallets {
-        let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+        let melt_quote = wallet
+            .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+            .await
+            .unwrap();
         melt_quotes.push(melt_quote);
     }
 
@@ -1560,9 +1678,12 @@ async fn test_concurrent_melt_same_invoice() {
     for (wallet, quote) in wallets.iter().zip(melt_quotes.iter()) {
         let wallet_clone = Arc::clone(wallet);
         let quote_id = quote.id.clone();
-        handles.push(tokio::spawn(
-            async move { wallet_clone.melt(&quote_id).await },
-        ));
+        handles.push(tokio::spawn(async move {
+            let prepared = wallet_clone
+                .prepare_melt(&quote_id, std::collections::HashMap::new())
+                .await?;
+            prepared.confirm().await
+        }));
     }
 
     // Collect results
@@ -1595,8 +1716,9 @@ async fn test_concurrent_melt_same_invoice() {
             assert!(
                 err_str.contains("duplicate")
                     || err_str.contains("already paid")
-                    || err_str.contains("pending"),
-                "Expected duplicate/already paid/pending error, got: {}",
+                    || err_str.contains("pending")
+                    || err_str.contains("payment failed"),
+                "Expected duplicate/already paid/pending/payment failed error, got: {}",
                 err
             );
         }
@@ -1616,7 +1738,7 @@ async fn test_wallet_proof_recovery_after_failed_swap() {
     .expect("failed to create new wallet");
 
     // Mint 100 sats
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_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()
@@ -1702,7 +1824,10 @@ async fn test_melt_proofs_external() {
     )
     .expect("failed to create sender wallet");
 
-    let mint_quote = wallet_sender.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet_sender
+        .mint_bolt11_quote(100.into(), None)
+        .await
+        .unwrap();
 
     let mut proof_streams =
         wallet_sender.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
@@ -1739,24 +1864,29 @@ async fn test_melt_proofs_external() {
 
     // Wallet B creates a melt quote
     let melt_quote = wallet_melter
-        .melt_quote(invoice.to_string(), None)
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
         .await
         .unwrap();
 
     // Wallet B calls melt_proofs with external proofs (from Wallet A)
     // These proofs are NOT in wallet_melter's database
-    let melted = wallet_melter
-        .melt_proofs(&melt_quote.id, proofs.clone())
+    let prepared = wallet_melter
+        .prepare_melt_proofs(
+            &melt_quote.id,
+            proofs.clone(),
+            std::collections::HashMap::new(),
+        )
         .await
         .unwrap();
+    let melted = prepared.confirm().await.unwrap();
 
     // Verify the melt succeeded
-    assert_eq!(melted.amount, Amount::from(9));
-    assert_eq!(melted.fee_paid, 1.into());
+    assert_eq!(melted.amount(), Amount::from(9));
+    assert_eq!(melted.fee_paid(), 1.into());
 
     // Verify change was returned (100 input - 9 melt amount = 91 change, minus fee reserve)
-    assert!(melted.change.is_some());
-    let change_amount = melted.change.unwrap().total_amount().unwrap();
+    assert!(melted.change().is_some());
+    let change_amount = melted.change().unwrap().total_amount().unwrap();
     assert!(change_amount > Amount::ZERO, "Should have received change");
 
     // Verify the melter wallet now has the change proofs
@@ -1792,7 +1922,7 @@ async fn test_melt_with_swap_for_exact_amount() {
     .expect("failed to create new wallet");
 
     // Mint 100 sats - this will give us proofs in standard denominations
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
@@ -1814,7 +1944,10 @@ async fn test_melt_with_swap_for_exact_amount() {
     let fake_description = FakeInvoiceDescription::default();
     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();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     tracing::info!(
         "Melt quote: amount={}, fee_reserve={}",
@@ -1823,15 +1956,19 @@ async fn test_melt_with_swap_for_exact_amount() {
     );
 
     // Call melt() - this should trigger swap-before-melt if proofs don't match exactly
-    let melted = wallet.melt(&melt_quote.id).await.unwrap();
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+    let melted = prepared.confirm().await.unwrap();
 
     // Verify the melt succeeded
-    assert_eq!(melted.amount, Amount::from(7));
+    assert_eq!(melted.amount(), Amount::from(7));
 
     tracing::info!(
         "Melt completed: amount={}, fee_paid={}",
-        melted.amount,
-        melted.fee_paid
+        melted.amount(),
+        melted.fee_paid()
     );
 
     // Verify final balance is correct (initial - melt_amount - fees)
@@ -1840,7 +1977,7 @@ async fn test_melt_with_swap_for_exact_amount() {
         "Balance: initial={}, final={}, paid={}",
         initial_balance,
         final_balance,
-        melted.amount + melted.fee_paid
+        melted.amount() + melted.fee_paid()
     );
 
     assert!(
@@ -1849,7 +1986,7 @@ async fn test_melt_with_swap_for_exact_amount() {
     );
     assert_eq!(
         final_balance,
-        initial_balance - melted.amount - melted.fee_paid,
+        initial_balance - melted.amount() - melted.fee_paid(),
         "Final balance should be initial - amount - fees"
     );
 }
@@ -1868,7 +2005,7 @@ async fn test_melt_exact_proofs_no_swap_needed() {
     .expect("failed to create new wallet");
 
     // Mint a larger amount to have more denomination options
-    let mint_quote = wallet.mint_quote(1000.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(1000.into(), None).await.unwrap();
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
@@ -1885,17 +2022,24 @@ async fn test_melt_exact_proofs_no_swap_needed() {
     let fake_description = FakeInvoiceDescription::default();
     let invoice = create_fake_invoice(64_000, serde_json::to_string(&fake_description).unwrap()); // 64 sats
 
-    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     // Melt should succeed
-    let melted = wallet.melt(&melt_quote.id).await.unwrap();
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+    let melted = prepared.confirm().await.unwrap();
 
-    assert_eq!(melted.amount, Amount::from(64));
+    assert_eq!(melted.amount(), Amount::from(64));
 
     let final_balance = wallet.total_balance().await.unwrap();
     assert_eq!(
         final_balance,
-        initial_balance - melted.amount - melted.fee_paid
+        initial_balance - melted.amount() - melted.fee_paid()
     );
 }
 
@@ -1917,7 +2061,7 @@ async fn test_check_all_mint_quotes_bolt11() {
     .expect("failed to create new wallet");
 
     // Create first mint quote and pay it (using proof_stream triggers fake wallet payment)
-    let mint_quote_1 = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote_1 = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     // Wait for the payment to be registered (fake wallet auto-pays)
     let mut payment_stream_1 = wallet.payment_stream(&mint_quote_1);
@@ -1928,7 +2072,7 @@ async fn test_check_all_mint_quotes_bolt11() {
         .expect("no error");
 
     // Create second mint quote and pay it
-    let mint_quote_2 = wallet.mint_quote(50.into(), None).await.unwrap();
+    let mint_quote_2 = wallet.mint_bolt11_quote(50.into(), None).await.unwrap();
 
     let mut payment_stream_2 = wallet.payment_stream(&mint_quote_2);
     payment_stream_2
@@ -1940,8 +2084,8 @@ async fn test_check_all_mint_quotes_bolt11() {
     // Verify no proofs have been minted yet
     assert_eq!(wallet.total_balance().await.unwrap(), Amount::ZERO);
 
-    // Call check_all_mint_quotes - this should mint both paid quotes
-    let total_minted = wallet.check_all_mint_quotes().await.unwrap();
+    // Call mint_unissued_quotes - this should mint both paid quotes
+    let total_minted = wallet.mint_unissued_quotes().await.unwrap();
 
     // Verify the total amount minted is correct (100 + 50 = 150)
     assert_eq!(total_minted, Amount::from(150));
@@ -1949,8 +2093,8 @@ async fn test_check_all_mint_quotes_bolt11() {
     // Verify wallet balance matches
     assert_eq!(wallet.total_balance().await.unwrap(), Amount::from(150));
 
-    // Calling check_all_mint_quotes again should return 0 (quotes already minted)
-    let second_check = wallet.check_all_mint_quotes().await.unwrap();
+    // Calling mint_unissued_quotes again should return 0 (quotes already minted)
+    let second_check = wallet.mint_unissued_quotes().await.unwrap();
     assert_eq!(second_check, Amount::ZERO);
 }
 
@@ -1973,10 +2117,10 @@ async fn test_get_unissued_mint_quotes_wallet() {
     .expect("failed to create new wallet");
 
     // Create a quote but don't pay it (stays unpaid)
-    let unpaid_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let unpaid_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     // Create another quote and pay it but don't mint
-    let paid_quote = wallet.mint_quote(50.into(), None).await.unwrap();
+    let paid_quote = wallet.mint_bolt11_quote(50.into(), None).await.unwrap();
     let mut payment_stream = wallet.payment_stream(&paid_quote);
     payment_stream
         .next()
@@ -1985,7 +2129,7 @@ async fn test_get_unissued_mint_quotes_wallet() {
         .expect("no error");
 
     // Create a third quote and fully mint it
-    let minted_quote = wallet.mint_quote(25.into(), None).await.unwrap();
+    let minted_quote = wallet.mint_bolt11_quote(25.into(), None).await.unwrap();
     let mut proof_stream = wallet.proof_stream(minted_quote.clone(), SplitTarget::default(), None);
     proof_stream
         .next()
@@ -2026,7 +2170,7 @@ async fn test_get_unissued_mint_quotes_wallet() {
 /// 2. Quote state is updated correctly
 /// 3. The quote is stored properly in the localstore
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_mint_quote_state_updates_after_minting() {
+async fn test_refresh_mint_quote_status_updates_after_minting() {
     let wallet = Wallet::new(
         MINT_URL,
         CurrencyUnit::Sat,
@@ -2037,7 +2181,7 @@ async fn test_mint_quote_state_updates_after_minting() {
     .expect("failed to create new wallet");
 
     let mint_amount = Amount::from(100);
-    let mint_quote = wallet.mint_quote(mint_amount, None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(mint_amount, None).await.unwrap();
 
     // Get the quote from localstore before minting
     let quote_before = wallet

+ 18 - 6
crates/cdk-integration-tests/tests/ffi_minting_integration.rs

@@ -20,7 +20,7 @@ use bip39::Mnemonic;
 use cdk_ffi::sqlite::WalletSqliteDatabase;
 use cdk_ffi::types::{encode_mint_quote, Amount, CurrencyUnit, QuoteState, SplitTarget};
 use cdk_ffi::wallet::Wallet as FfiWallet;
-use cdk_ffi::WalletConfig;
+use cdk_ffi::{PaymentMethod, WalletConfig};
 use cdk_integration_tests::{get_mint_url_from_env, pay_if_regtest};
 use lightning_invoice::Bolt11Invoice;
 use tokio::time::timeout;
@@ -80,7 +80,12 @@ async fn test_ffi_full_minting_flow() {
 
     // Step 1: Create a mint quote
     let quote = wallet
-        .mint_quote(mint_amount, Some("FFI Integration Test".to_string()))
+        .mint_quote(
+            PaymentMethod::Bolt11,
+            Some(mint_amount),
+            Some("FFI Integration Test".to_string()),
+            None,
+        )
         .await
         .expect("Failed to create mint quote");
 
@@ -102,9 +107,9 @@ async fn test_ffi_full_minting_flow() {
     );
     assert!(!quote.id.is_empty(), "Quote should have an ID");
 
-    // Check  mint quote status
+    // Refresh mint quote status
     let quote_status = wallet
-        .check_mint_quote(quote.id.clone())
+        .refresh_mint_quote(quote.id.clone())
         .await
         .expect("failed to get mint status");
     assert_eq!(
@@ -234,7 +239,12 @@ async fn test_ffi_mint_quote_creation() {
         let description = format!("Test quote for {} sats", amount_value);
 
         let quote = wallet
-            .mint_quote(amount, Some(description.clone()))
+            .mint_quote(
+                PaymentMethod::Bolt11,
+                Some(amount),
+                Some(description.clone()),
+                None,
+            )
             .await
             .unwrap_or_else(|_| panic!("Failed to create quote for {} sats", amount_value));
 
@@ -301,7 +311,9 @@ async fn test_ffi_minting_error_handling() {
     let wallet = create_test_ffi_wallet().await;
 
     // Test zero amount quote (should fail)
-    let zero_amount_result = wallet.mint_quote(Amount::new(0), None).await;
+    let zero_amount_result = wallet
+        .mint_quote(PaymentMethod::Bolt11, Some(Amount::new(0)), None, None)
+        .await;
     assert!(
         zero_amount_result.is_err(),
         "Should fail to create quote with zero amount"

+ 62 - 26
crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs

@@ -109,7 +109,7 @@ async fn test_happy_mint_melt_round_trip() {
     .expect("Failed to connect");
     let (mut write, mut reader) = ws_stream.split();
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap();
     pay_if_regtest(&get_test_temp_dir(), &invoice)
@@ -132,7 +132,10 @@ async fn test_happy_mint_melt_round_trip() {
 
     let invoice = create_invoice_for_env(Some(50)).await.unwrap();
 
-    let melt = wallet.melt_quote(invoice, None).await.unwrap();
+    let melt = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice, None, None)
+        .await
+        .unwrap();
 
     write
         .send(Message::Text(
@@ -184,12 +187,13 @@ async fn test_happy_mint_melt_round_trip() {
     let mut metadata = HashMap::new();
     metadata.insert("test".to_string(), "value".to_string());
 
-    let melt_response = wallet
-        .melt_with_metadata(&melt.id, metadata.clone())
+    let prepared = wallet
+        .prepare_melt(&melt.id, metadata.clone())
         .await
         .unwrap();
-    assert!(melt_response.preimage.is_some());
-    assert_eq!(melt_response.state, MeltQuoteState::Paid);
+    let melt_response = prepared.confirm().await.unwrap();
+    assert!(melt_response.payment_proof().is_some());
+    assert_eq!(melt_response.state(), MeltQuoteState::Paid);
 
     let txs = wallet.list_transactions(None).await.unwrap();
     let tx = txs
@@ -245,7 +249,7 @@ async fn test_happy_mint() {
 
     let mint_amount = Amount::from(100);
 
-    let mint_quote = wallet.mint_quote(mint_amount, None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(mint_amount, None).await.unwrap();
 
     assert_eq!(mint_quote.amount, Some(mint_amount));
 
@@ -295,7 +299,7 @@ async fn test_restore() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap();
     pay_if_regtest(&get_test_temp_dir(), &invoice)
@@ -388,7 +392,7 @@ async fn test_restore_large_proof_count() {
     while remaining > 0 {
         let batch = remaining.min(batch_size);
 
-        let mint_quote = wallet.mint_quote(batch.into(), None).await.unwrap();
+        let mint_quote = wallet.mint_bolt11_quote(batch.into(), None).await.unwrap();
 
         let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap();
         pay_if_regtest(&get_test_temp_dir(), &invoice)
@@ -486,7 +490,7 @@ async fn test_restore_with_counter_gap() {
     .expect("failed to create new wallet");
 
     // Mint first batch of proofs (uses counters starting at 0)
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
     let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap();
     pay_if_regtest(&get_test_temp_dir(), &invoice)
         .await
@@ -519,7 +523,7 @@ async fn test_restore_with_counter_gap() {
         .unwrap();
 
     // Mint second batch of proofs (uses counters after the gap)
-    let mint_quote2 = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote2 = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
     let invoice2 = Bolt11Invoice::from_str(&mint_quote2.request).unwrap();
     pay_if_regtest(&get_test_temp_dir(), &invoice2)
         .await
@@ -622,7 +626,7 @@ async fn test_melt_quote_status_after_melt() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap();
     pay_if_regtest(&get_test_temp_dir(), &invoice)
@@ -644,12 +648,22 @@ async fn test_melt_quote_status_after_melt() {
 
     let invoice = create_invoice_for_env(Some(50)).await.unwrap();
 
-    let melt_quote = wallet.melt_quote(invoice, None).await.unwrap();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice, None, None)
+        .await
+        .unwrap();
 
-    let melt_response = wallet.melt(&melt_quote.id).await.unwrap();
-    assert_eq!(melt_response.state, MeltQuoteState::Paid);
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+    let melt_response = prepared.confirm().await.unwrap();
+    assert_eq!(melt_response.state(), MeltQuoteState::Paid);
 
-    let quote_status = wallet.melt_quote_status(&melt_quote.id).await.unwrap();
+    let quote_status = wallet
+        .check_melt_quote_status(&melt_quote.id)
+        .await
+        .unwrap();
     assert_eq!(
         quote_status.state,
         MeltQuoteState::Paid,
@@ -729,7 +743,7 @@ async fn test_melt_quote_status_after_melt_multi_mint_wallet() {
         .melt_with_mint(&mint_url, &melt_quote.id)
         .await
         .unwrap();
-    assert_eq!(melt_response.state, MeltQuoteState::Paid);
+    assert_eq!(melt_response.state(), MeltQuoteState::Paid);
 
     let quote_status = multi_mint_wallet
         .check_melt_quote(&mint_url, &melt_quote.id)
@@ -777,7 +791,7 @@ async fn test_fake_melt_change_in_quote() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let bolt11 = Bolt11Invoice::from_str(&mint_quote.request).unwrap();
 
@@ -797,7 +811,10 @@ async fn test_fake_melt_change_in_quote() {
 
     let proofs = wallet.get_unspent_proofs().await.unwrap();
 
-    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     let keyset = wallet.fetch_active_keyset().await.unwrap();
     let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
@@ -825,7 +842,7 @@ async fn test_fake_melt_change_in_quote() {
 
     assert!(melt_response.change.is_some());
 
-    let check = wallet.melt_quote_status(&melt_quote.id).await.unwrap();
+    let check = client.get_melt_quote_status(&melt_quote.id).await.unwrap();
     let mut melt_change = melt_response.change.unwrap();
     melt_change.sort_by(|a, b| a.amount.cmp(&b.amount));
 
@@ -856,7 +873,7 @@ async fn test_pay_invoice_twice() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     pay_if_regtest(&get_test_temp_dir(), &mint_quote.request.parse().unwrap())
         .await
@@ -878,16 +895,32 @@ async fn test_pay_invoice_twice() {
 
     let invoice = create_invoice_for_env(Some(25)).await.unwrap();
 
-    let melt_quote = wallet.melt_quote(invoice.clone(), None).await.unwrap();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.clone(), None, None)
+        .await
+        .unwrap();
 
-    let melt = wallet.melt(&melt_quote.id).await.unwrap();
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+    let melt = prepared.confirm().await.unwrap();
 
     // Creating a second quote for the same invoice is allowed
-    let melt_quote_two = wallet.melt_quote(invoice, None).await.unwrap();
+    let melt_quote_two = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice, None, None)
+        .await
+        .unwrap();
 
     // But attempting to melt (pay) the second quote should fail
     // since the first quote with the same lookup_id is already paid
-    let melt_two = wallet.melt(&melt_quote_two.id).await;
+    let melt_two = async {
+        let prepared = wallet
+            .prepare_melt(&melt_quote_two.id, std::collections::HashMap::new())
+            .await?;
+        prepared.confirm().await
+    }
+    .await;
 
     match melt_two {
         Err(err) => {
@@ -909,5 +942,8 @@ async fn test_pay_invoice_twice() {
 
     let balance = wallet.total_balance().await.unwrap();
 
-    assert_eq!(balance, (Amount::from(100) - melt.fee_paid - melt.amount));
+    assert_eq!(
+        balance,
+        (Amount::from(100) - melt.fee_paid() - melt.amount())
+    );
 }

+ 9 - 7
crates/cdk-integration-tests/tests/integration_tests_pure.rs

@@ -20,8 +20,8 @@ use cashu::amount::SplitTarget;
 use cashu::dhke::construct_proofs;
 use cashu::mint_url::MintUrl;
 use cashu::{
-    CurrencyUnit, Id, MeltRequest, NotificationPayload, PreMintSecrets, ProofState, SecretKey,
-    SpendingConditions, State, SwapRequest,
+    CurrencyUnit, Id, MeltRequest, NotificationPayload, PaymentMethod, PreMintSecrets, ProofState,
+    SecretKey, SpendingConditions, State, SwapRequest,
 };
 use cdk::mint::Mint;
 use cdk::nuts::nut00::ProofsMethods;
@@ -131,6 +131,7 @@ async fn test_swap_to_send() {
             token_proofs.clone(),
             ReceiveOptions::default(),
             token.memo().clone(),
+            Some(token.to_string()),
         )
         .await
         .expect("Failed to receive proofs");
@@ -804,16 +805,17 @@ async fn test_mint_change_with_fee_melt() {
     let fake_invoice = create_fake_invoice(1000, "".to_string());
 
     let melt_quote = wallet_alice
-        .melt_quote(fake_invoice.to_string(), None)
+        .melt_quote(PaymentMethod::BOLT11, fake_invoice.to_string(), None, None)
         .await
         .unwrap();
 
-    let w = wallet_alice
-        .melt_proofs(&melt_quote.id, proofs)
+    let prepared = wallet_alice
+        .prepare_melt_proofs(&melt_quote.id, proofs, std::collections::HashMap::new())
         .await
         .unwrap();
+    let w = prepared.confirm().await.unwrap();
 
-    assert_eq!(w.change.unwrap().total_amount().unwrap(), 97.into());
+    assert_eq!(w.change().unwrap().total_amount().unwrap(), 97.into());
 
     // Check amounts after melting
     // Melting redeems 100 sats and issues 97 sats as change
@@ -972,7 +974,7 @@ async fn test_concurrent_double_spend_melt() {
 
     // Create a melt quote
     let melt_quote = wallet_alice
-        .melt_quote(invoice.to_string(), None)
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
         .await
         .expect("Failed to create melt quote");
 

+ 73 - 13
crates/cdk-integration-tests/tests/multi_mint_wallet.rs

@@ -18,7 +18,7 @@ use cdk::amount::{Amount, SplitTarget};
 use cdk::mint_url::MintUrl;
 use cdk::nuts::nut00::ProofsMethods;
 use cdk::nuts::{CurrencyUnit, MeltQuoteState, MintQuoteState, Token};
-use cdk::wallet::{MultiMintReceiveOptions, MultiMintSendOptions, MultiMintWallet};
+use cdk::wallet::{MultiMintReceiveOptions, MultiMintWallet, SendOptions};
 use cdk_integration_tests::{create_invoice_for_env, get_mint_url_from_env, pay_if_regtest};
 use cdk_sqlite::wallet::memory;
 use lightning_invoice::Bolt11Invoice;
@@ -100,7 +100,7 @@ async fn test_multi_mint_wallet_mint() {
 
     // Poll for quote to be paid (like a real wallet would)
     let mut quote_status = multi_mint_wallet
-        .check_mint_quote(&mint_url, &mint_quote.id)
+        .refresh_mint_quote(&mint_url, &mint_quote.id)
         .await
         .unwrap();
 
@@ -116,7 +116,7 @@ async fn test_multi_mint_wallet_mint() {
         }
         tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
         quote_status = multi_mint_wallet
-            .check_mint_quote(&mint_url, &mint_quote.id)
+            .refresh_mint_quote(&mint_url, &mint_quote.id)
             .await
             .unwrap();
     }
@@ -162,11 +162,11 @@ async fn test_multi_mint_wallet_melt_auto_select() {
     let melt_result = multi_mint_wallet.melt(&invoice, None, None).await.unwrap();
 
     assert_eq!(
-        melt_result.state,
+        melt_result.state(),
         MeltQuoteState::Paid,
         "Melt should be paid"
     );
-    assert_eq!(melt_result.amount, 50.into(), "Should melt 50 sats");
+    assert_eq!(melt_result.amount(), 50.into(), "Should melt 50 sats");
 
     // Verify balance decreased
     let balance = multi_mint_wallet.total_balance().await.unwrap();
@@ -196,7 +196,7 @@ async fn test_multi_mint_wallet_receive() {
     assert_eq!(funded_amount, 100.into());
 
     // Create a token to send
-    let send_options = MultiMintSendOptions::default();
+    let send_options = SendOptions::default();
     let prepared_send = sender_wallet
         .prepare_send(mint_url.clone(), 50.into(), send_options)
         .await
@@ -262,7 +262,7 @@ async fn test_multi_mint_wallet_receive_untrusted() {
     assert_eq!(funded_amount, 100.into());
 
     // Create a token to send
-    let send_options = MultiMintSendOptions::default();
+    let send_options = SendOptions::default();
     let prepared_send = sender_wallet
         .prepare_send(mint_url.clone(), 50.into(), send_options)
         .await
@@ -319,7 +319,7 @@ async fn test_multi_mint_wallet_prepare_send_happy_path() {
     assert_eq!(funded_amount, 100.into());
 
     // Prepare send
-    let send_options = MultiMintSendOptions::default();
+    let send_options = SendOptions::default();
     let prepared_send = multi_mint_wallet
         .prepare_send(mint_url.clone(), 50.into(), send_options)
         .await
@@ -494,7 +494,7 @@ async fn test_multi_mint_wallet_check_all_mint_quotes() {
 
     // Poll for quote to be paid (like a real wallet would)
     let mut quote_status = multi_mint_wallet
-        .check_mint_quote(&mint_url, &mint_quote.id)
+        .refresh_mint_quote(&mint_url, &mint_quote.id)
         .await
         .unwrap();
 
@@ -507,16 +507,15 @@ async fn test_multi_mint_wallet_check_all_mint_quotes() {
                 quote_status.state
             );
         }
-        tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
         quote_status = multi_mint_wallet
-            .check_mint_quote(&mint_url, &mint_quote.id)
+            .refresh_mint_quote(&mint_url, &mint_quote.id)
             .await
             .unwrap();
     }
 
     // Check all mint quotes - this should find the paid quote and mint
     let minted_amount = multi_mint_wallet
-        .check_all_mint_quotes(Some(mint_url.clone()))
+        .mint_unissued_quotes(Some(mint_url.clone()))
         .await
         .unwrap();
 
@@ -615,7 +614,7 @@ async fn test_multi_mint_wallet_melt_with_mint() {
         .unwrap();
 
     assert_eq!(
-        melt_result.state,
+        melt_result.state(),
         MeltQuoteState::Paid,
         "Melt should be paid"
     );
@@ -690,3 +689,64 @@ async fn test_multi_mint_wallet_list_transactions() {
         "Should have more transactions after melt"
     );
 }
+
+/// Test send revocation via MultiMintWallet
+///
+/// This test verifies:
+/// 1. Create and confirm a send
+/// 2. Verify it appears in pending sends
+/// 3. Verify status is "not claimed"
+/// 4. Revoke the send
+/// 5. Verify balance is restored and pending send is gone
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_multi_mint_wallet_revoke_send() {
+    let multi_mint_wallet = create_test_multi_mint_wallet().await;
+
+    let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url");
+    multi_mint_wallet
+        .add_mint(mint_url.clone())
+        .await
+        .expect("failed to add mint");
+
+    // Fund the wallet
+    fund_multi_mint_wallet(&multi_mint_wallet, &mint_url, 100.into()).await;
+
+    // Create a send
+    let send_options = SendOptions::default();
+    let prepared_send = multi_mint_wallet
+        .prepare_send(mint_url.clone(), 50.into(), send_options)
+        .await
+        .unwrap();
+
+    let operation_id = prepared_send.operation_id();
+    let _token = prepared_send.confirm(None).await.unwrap();
+
+    // Verify it appears in pending sends
+    let pending = multi_mint_wallet.get_pending_sends().await.unwrap();
+    assert_eq!(pending.len(), 1, "Should have 1 pending send");
+    assert_eq!(pending[0].0, mint_url, "Mint URL should match");
+    assert_eq!(pending[0].1, operation_id, "Operation ID should match");
+
+    // Verify status
+    let claimed = multi_mint_wallet
+        .check_send_status(mint_url.clone(), operation_id)
+        .await
+        .unwrap();
+    assert!(!claimed, "Token should not be claimed yet");
+
+    // Revoke the send
+    let restored_amount = multi_mint_wallet
+        .revoke_send(mint_url.clone(), operation_id)
+        .await
+        .unwrap();
+
+    assert_eq!(restored_amount, 50.into(), "Should restore 50 sats");
+
+    // Verify pending send is gone
+    let pending_after = multi_mint_wallet.get_pending_sends().await.unwrap();
+    assert!(pending_after.is_empty(), "Should have no pending sends");
+
+    // Verify balance is back to 100
+    let balance = multi_mint_wallet.total_balance().await.unwrap();
+    assert_eq!(balance, 100.into(), "Balance should be fully restored");
+}

+ 78 - 32
crates/cdk-integration-tests/tests/regtest.rs

@@ -25,7 +25,9 @@ use cdk::nuts::{
     NotificationPayload, PaymentMethod, PreMintSecrets,
 };
 use cdk::wallet::{HttpClient, MintConnector, Wallet, WalletSubscription};
-use cdk_integration_tests::{get_mint_url_from_env, get_second_mint_url_from_env, get_test_client};
+use cdk_integration_tests::{
+    attempt_manual_mint, get_mint_url_from_env, get_second_mint_url_from_env, get_test_client,
+};
 use cdk_sqlite::wallet::{self, memory};
 use futures::join;
 use tokio::time::timeout;
@@ -45,7 +47,7 @@ async fn test_internal_payment() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     ln_client
         .pay_invoice(mint_quote.request.clone())
@@ -73,16 +75,25 @@ async fn test_internal_payment() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet_2.mint_quote(10.into(), None).await.unwrap();
+    let mint_quote = wallet_2.mint_bolt11_quote(10.into(), None).await.unwrap();
 
     let melt = wallet
-        .melt_quote(mint_quote.request.clone(), None)
+        .melt_quote(
+            PaymentMethod::BOLT11,
+            mint_quote.request.clone(),
+            None,
+            None,
+        )
         .await
         .unwrap();
 
     assert_eq!(melt.amount, 10.into());
 
-    let _melted = wallet.melt(&melt.id).await.unwrap();
+    let prepared = wallet
+        .prepare_melt(&melt.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+    let _melted = prepared.confirm().await.unwrap();
 
     let _proofs = wallet_2
         .wait_and_mint_quote(
@@ -151,7 +162,7 @@ async fn test_websocket_connection() {
     .expect("failed to create new wallet");
 
     // Create a small mint quote to test notifications
-    let mint_quote = wallet.mint_quote(10.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(10.into(), None).await.unwrap();
 
     // Subscribe to notifications for this quote
     let mut subscription = wallet
@@ -227,7 +238,7 @@ async fn test_multimint_melt() {
     let mint_amount = Amount::from(100);
 
     // Fund the wallets
-    let quote = wallet1.mint_quote(mint_amount, None).await.unwrap();
+    let quote = wallet1.mint_bolt11_quote(mint_amount, None).await.unwrap();
     ln_client
         .pay_invoice(quote.request.clone())
         .await
@@ -243,7 +254,7 @@ async fn test_multimint_melt() {
         .await
         .expect("payment");
 
-    let quote = wallet2.mint_quote(mint_amount, None).await.unwrap();
+    let quote = wallet2.mint_bolt11_quote(mint_amount, None).await.unwrap();
     ln_client
         .pay_invoice(quote.request.clone())
         .await
@@ -269,26 +280,44 @@ async fn test_multimint_melt() {
         },
     };
     let quote_1 = wallet1
-        .melt_quote(invoice.clone(), Some(melt_options))
+        .melt_quote(
+            PaymentMethod::BOLT11,
+            invoice.clone(),
+            Some(melt_options),
+            None,
+        )
         .await
         .expect("Could not get melt quote");
     let quote_2 = wallet2
-        .melt_quote(invoice.clone(), Some(melt_options))
+        .melt_quote(
+            PaymentMethod::BOLT11,
+            invoice.clone(),
+            Some(melt_options),
+            None,
+        )
         .await
         .expect("Could not get melt quote");
 
-    // Multimint pay invoice
-    let result1 = wallet1.melt(&quote_1.id);
-    let result2 = wallet2.melt(&quote_2.id);
-    let result = join!(result1, result2);
+    // Multimint pay invoice - prepare both melts
+    let prepared1 = wallet1
+        .prepare_melt(&quote_1.id, std::collections::HashMap::new())
+        .await
+        .expect("Could not prepare melt 1");
+    let prepared2 = wallet2
+        .prepare_melt(&quote_2.id, std::collections::HashMap::new())
+        .await
+        .expect("Could not prepare melt 2");
+
+    // Confirm both in parallel
+    let result = join!(prepared1.confirm(), prepared2.confirm());
 
     // Unpack results
     let result1 = result.0.unwrap();
     let result2 = result.1.unwrap();
 
     // Check
-    assert!(result1.state == result2.state);
-    assert!(result1.state == MeltQuoteState::Paid);
+    assert!(result1.state() == result2.state());
+    assert!(result1.state() == MeltQuoteState::Paid);
 }
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
@@ -305,7 +334,7 @@ async fn test_cached_mint() {
 
     let mint_amount = Amount::from(100);
 
-    let quote = wallet.mint_quote(mint_amount, None).await.unwrap();
+    let quote = wallet.mint_bolt11_quote(mint_amount, None).await.unwrap();
     ln_client
         .pay_invoice(quote.request.clone())
         .await
@@ -370,7 +399,7 @@ async fn test_regtest_melt_amountless() {
 
     let mint_amount = Amount::from(100);
 
-    let mint_quote = wallet.mint_quote(mint_amount, None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(mint_amount, None).await.unwrap();
 
     assert_eq!(mint_quote.amount, Some(mint_amount));
 
@@ -393,13 +422,17 @@ async fn test_regtest_melt_amountless() {
     let options = MeltOptions::new_amountless(5_000);
 
     let melt_quote = wallet
-        .melt_quote(invoice.clone(), Some(options))
+        .melt_quote(PaymentMethod::BOLT11, invoice.clone(), Some(options), None)
         .await
         .unwrap();
 
-    let melt = wallet.melt(&melt_quote.id).await.unwrap();
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+    let melt = prepared.confirm().await.unwrap();
 
-    assert!(melt.amount == 5.into());
+    assert!(melt.amount() == 5.into());
 }
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
@@ -415,15 +448,20 @@ async fn test_attempt_to_mint_unpaid() {
 
     let mint_amount = Amount::from(100);
 
-    let mint_quote = wallet.mint_quote(mint_amount, None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(mint_amount, None).await.unwrap();
 
     assert_eq!(mint_quote.amount, Some(mint_amount));
 
-    let proofs = wallet
-        .mint(&mint_quote.id, SplitTarget::default(), None)
-        .await;
+    let response = attempt_manual_mint(
+        &wallet,
+        &get_mint_url_from_env(),
+        &mint_quote,
+        mint_amount,
+        PaymentMethod::Known(KnownMethod::Bolt11),
+    )
+    .await;
 
-    match proofs {
+    match response {
         Err(err) => {
             if !matches!(err, cdk::Error::UnpaidQuote) {
                 panic!("Wrong error quote should be unpaid: {}", err);
@@ -434,17 +472,25 @@ async fn test_attempt_to_mint_unpaid() {
         }
     }
 
-    let mint_quote = wallet.mint_quote(mint_amount, None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(mint_amount, None).await.unwrap();
 
-    let state = wallet.mint_quote_state(&mint_quote.id).await.unwrap();
+    let state = wallet
+        .refresh_mint_quote_status(&mint_quote.id)
+        .await
+        .unwrap();
 
     assert!(state.state == MintQuoteState::Unpaid);
 
-    let proofs = wallet
-        .mint(&mint_quote.id, SplitTarget::default(), None)
-        .await;
+    let response = attempt_manual_mint(
+        &wallet,
+        &get_mint_url_from_env(),
+        &mint_quote,
+        mint_amount,
+        PaymentMethod::Known(KnownMethod::Bolt11),
+    )
+    .await;
 
-    match proofs {
+    match response {
         Err(err) => {
             if !matches!(err, cdk::Error::UnpaidQuote) {
                 panic!("Wrong error quote should be unpaid: {}", err);

+ 15 - 7
crates/cdk-integration-tests/tests/test_fees.rs

@@ -2,7 +2,7 @@ use std::str::FromStr;
 use std::sync::Arc;
 
 use bip39::Mnemonic;
-use cashu::{Bolt11Invoice, ProofsMethods};
+use cashu::{Bolt11Invoice, PaymentMethod, ProofsMethods};
 use cdk::amount::{Amount, SplitTarget};
 use cdk::nuts::CurrencyUnit;
 use cdk::wallet::{ReceiveOptions, SendKind, SendOptions, Wallet};
@@ -28,7 +28,7 @@ async fn test_swap() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap();
     pay_if_regtest(&get_temp_dir(), &invoice).await.unwrap();
@@ -89,7 +89,7 @@ async fn test_fake_melt_change_in_quote() {
     )
     .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mint_quote = wallet.mint_bolt11_quote(100.into(), None).await.unwrap();
 
     let bolt11 = Bolt11Invoice::from_str(&mint_quote.request).unwrap();
 
@@ -109,18 +109,26 @@ async fn test_fake_melt_change_in_quote() {
 
     let invoice = create_invoice_for_env(Some(invoice_amount)).await.unwrap();
 
-    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     let proofs = wallet.get_unspent_proofs().await.unwrap();
 
     let proofs_total = proofs.total_amount().unwrap();
 
     let fee_breakdown = wallet.get_proofs_fee(&proofs).await.unwrap();
-    let melt = wallet
-        .melt_proofs(&melt_quote.id, proofs.clone())
+    let prepared = wallet
+        .prepare_melt_proofs(
+            &melt_quote.id,
+            proofs.clone(),
+            std::collections::HashMap::new(),
+        )
         .await
         .unwrap();
-    let change = melt.change.unwrap().total_amount().unwrap();
+    let melt = prepared.confirm().await.unwrap();
+    let change = melt.change().unwrap().total_amount().unwrap();
     let idk = proofs.total_amount().unwrap() - Amount::from(invoice_amount) - change;
 
     println!("{}", idk);

+ 52 - 21
crates/cdk-integration-tests/tests/test_swap_flow.rs

@@ -14,7 +14,10 @@ use std::sync::Arc;
 
 use cashu::amount::SplitTarget;
 use cashu::dhke::construct_proofs;
-use cashu::{CurrencyUnit, Id, PreMintSecrets, SecretKey, SpendingConditions, State, SwapRequest};
+use cashu::{
+    CurrencyUnit, Id, PaymentMethod, PreMintSecrets, SecretKey, SpendingConditions, State,
+    SwapRequest,
+};
 use cdk::mint::Mint;
 use cdk::nuts::nut00::ProofsMethods;
 use cdk::Amount;
@@ -851,7 +854,10 @@ async fn test_melt_with_fees_swap_before_melt() {
     // 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 melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     let quote_amount: u64 = melt_quote.amount.into();
     let fee_reserve: u64 = melt_quote.fee_reserve.into();
@@ -871,10 +877,14 @@ async fn test_melt_with_fees_swap_before_melt() {
     );
 
     // Perform melt
-    let melted = wallet.melt(&melt_quote.id).await.unwrap();
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+    let melted = prepared.confirm().await.unwrap();
 
-    let melt_amount: u64 = melted.amount.into();
-    let ln_fee_paid: u64 = melted.fee_paid.into();
+    let melt_amount: u64 = melted.amount().into();
+    let ln_fee_paid: u64 = melted.fee_paid().into();
 
     tracing::info!(
         "Melt completed: amount={}, ln_fee_paid={}",
@@ -979,7 +989,10 @@ async fn test_melt_exact_match_no_swap() {
     // 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 melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     let quote_amount: u64 = melt_quote.amount.into();
     let fee_reserve: u64 = melt_quote.fee_reserve.into();
@@ -993,10 +1006,14 @@ async fn test_melt_exact_match_no_swap() {
     );
 
     // Perform melt
-    let melted = wallet.melt(&melt_quote.id).await.unwrap();
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+    let melted = prepared.confirm().await.unwrap();
 
-    let melt_amount: u64 = melted.amount.into();
-    let ln_fee_paid: u64 = melted.fee_paid.into();
+    let melt_amount: u64 = melted.amount().into();
+    let ln_fee_paid: u64 = melted.fee_paid().into();
 
     tracing::info!(
         "Melt completed: amount={}, ln_fee_paid={}",
@@ -1084,7 +1101,10 @@ async fn test_melt_small_amount_tight_margin() {
     // 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 melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     let quote_amount: u64 = melt_quote.amount.into();
     let fee_reserve: u64 = melt_quote.fee_reserve.into();
@@ -1097,19 +1117,23 @@ async fn test_melt_small_amount_tight_margin() {
     );
 
     // This should succeed even with tight margins
-    let melted = wallet
-        .melt(&melt_quote.id)
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .expect("Prepare melt should succeed");
+    let melted = prepared
+        .confirm()
         .await
         .expect("Melt should succeed even with tight swap margin");
 
-    let melt_amount: u64 = melted.amount.into();
+    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,
+        melted.amount(),
+        melted.fee_paid(),
         final_balance
     );
 
@@ -1185,7 +1209,10 @@ async fn test_melt_swap_tight_margin_regression() {
     // 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 melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
 
     let quote_amount: u64 = melt_quote.amount.into();
     let fee_reserve: u64 = melt_quote.fee_reserve.into();
@@ -1200,19 +1227,23 @@ async fn test_melt_swap_tight_margin_regression() {
     // 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)
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .expect("Prepare melt should succeed");
+    let melted = prepared
+        .confirm()
         .await
         .expect("Melt should succeed with swap-before-melt (regression test)");
 
-    let melt_amount: u64 = melted.amount.into();
+    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,
+        melted.amount(),
+        melted.fee_paid(),
         final_balance
     );
 

+ 524 - 0
crates/cdk-integration-tests/tests/wallet_saga.rs

@@ -0,0 +1,524 @@
+//! 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(&quote_id1, std::collections::HashMap::new())
+        .await?;
+    let prepared2 = wallet2
+        .prepare_melt(&quote_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
+    )
+    .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
+    )
+    .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,
+    )
+    .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(())
+}

+ 2 - 0
crates/cdk-npubcash/src/types.rs

@@ -164,6 +164,8 @@ impl From<Quote> for MintQuote {
             } else {
                 Amount::ZERO
             },
+            used_by_operation: None,
+            version: 0,
         }
     }
 }

+ 457 - 30
crates/cdk-redb/src/wallet/mod.rs

@@ -7,12 +7,13 @@ use std::str::FromStr;
 use std::sync::Arc;
 
 use async_trait::async_trait;
-use cdk_common::common::ProofInfo;
 use cdk_common::database::{validate_kvstore_params, WalletDatabase};
 use cdk_common::mint_url::MintUrl;
 use cdk_common::nut00::KnownMethod;
 use cdk_common::util::unix_time;
-use cdk_common::wallet::{self, MintQuote, Transaction, TransactionDirection, TransactionId};
+use cdk_common::wallet::{
+    self, MintQuote, ProofInfo, Transaction, TransactionDirection, TransactionId,
+};
 use cdk_common::{
     database, Amount, CurrencyUnit, Id, KeySet, KeySetInfo, Keys, MintInfo, PaymentMethod,
     PublicKey, SpendingConditions, State,
@@ -44,6 +45,8 @@ const CONFIG_TABLE: TableDefinition<&str, &str> = TableDefinition::new("config")
 const KEYSET_COUNTER: TableDefinition<&str, u32> = TableDefinition::new("keyset_counter");
 // <Transaction_id, Transaction>
 const TRANSACTIONS_TABLE: TableDefinition<&[u8], &str> = TableDefinition::new("transactions");
+// <Saga_id, WalletSaga>
+const SAGAS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("wallet_sagas");
 
 const KEYSET_U32_MAPPING: TableDefinition<u32, &str> = TableDefinition::new("keyset_u32_mapping");
 // <(primary_namespace, secondary_namespace, key), value>
@@ -814,10 +817,32 @@ impl WalletDatabase<database::Error> for WalletRedbDatabase {
             let mut table = write_txn
                 .open_table(MINT_QUOTES_TABLE)
                 .map_err(Error::from)?;
+
+            // Check for existing quote and version match
+            let existing_quote_json = table
+                .get(quote.id.as_str())
+                .map_err(Error::from)?
+                .map(|v| v.value().to_string());
+
+            let mut quote_to_save = quote.clone();
+
+            if let Some(json) = existing_quote_json {
+                let existing_quote: MintQuote = serde_json::from_str(&json).map_err(Error::from)?;
+
+                if existing_quote.version != quote.version {
+                    return Err(database::Error::ConcurrentUpdate);
+                }
+
+                // Increment version for update
+                quote_to_save.version = quote.version.wrapping_add(1);
+            }
+
             table
                 .insert(
-                    quote.id.as_str(),
-                    serde_json::to_string(&quote).map_err(Error::from)?.as_str(),
+                    quote_to_save.id.as_str(),
+                    serde_json::to_string(&quote_to_save)
+                        .map_err(Error::from)?
+                        .as_str(),
                 )
                 .map_err(Error::from)?;
         }
@@ -845,10 +870,33 @@ impl WalletDatabase<database::Error> for WalletRedbDatabase {
             let mut table = write_txn
                 .open_table(MELT_QUOTES_TABLE)
                 .map_err(Error::from)?;
+
+            // Check for existing quote and version match
+            let existing_quote_json = table
+                .get(quote.id.as_str())
+                .map_err(Error::from)?
+                .map(|v| v.value().to_string());
+
+            let mut quote_to_save = quote.clone();
+
+            if let Some(json) = existing_quote_json {
+                let existing_quote: wallet::MeltQuote =
+                    serde_json::from_str(&json).map_err(Error::from)?;
+
+                if existing_quote.version != quote.version {
+                    return Err(database::Error::ConcurrentUpdate);
+                }
+
+                // Increment version for update
+                quote_to_save.version = quote.version.wrapping_add(1);
+            }
+
             table
                 .insert(
-                    quote.id.as_str(),
-                    serde_json::to_string(&quote).map_err(Error::from)?.as_str(),
+                    quote_to_save.id.as_str(),
+                    serde_json::to_string(&quote_to_save)
+                        .map_err(Error::from)?
+                        .as_str(),
                 )
                 .map_err(Error::from)?;
         }
@@ -945,7 +993,409 @@ impl WalletDatabase<database::Error> for WalletRedbDatabase {
         Ok(())
     }
 
-    // KV Store methods
+    #[instrument(skip(self))]
+    async fn add_saga(&self, saga: wallet::WalletSaga) -> Result<(), database::Error> {
+        let saga_json = serde_json::to_string(&saga).map_err(Error::from)?;
+        let id_str = saga.id.to_string();
+
+        let write_txn = self.db.begin_write().map_err(Error::from)?;
+        {
+            let mut table = write_txn.open_table(SAGAS_TABLE).map_err(Error::from)?;
+            table
+                .insert(id_str.as_str(), saga_json.as_str())
+                .map_err(Error::from)?;
+        }
+        write_txn.commit().map_err(Error::from)?;
+        Ok(())
+    }
+
+    #[instrument(skip(self))]
+    async fn get_saga(
+        &self,
+        id: &uuid::Uuid,
+    ) -> Result<Option<wallet::WalletSaga>, database::Error> {
+        let read_txn = self.db.begin_read().map_err(Error::from)?;
+        let table = read_txn.open_table(SAGAS_TABLE).map_err(Error::from)?;
+        let id_str = id.to_string();
+
+        let result = table
+            .get(id_str.as_str())
+            .map_err(Error::from)?
+            .map(|saga| serde_json::from_str(saga.value()).map_err(Error::from))
+            .transpose()?;
+
+        Ok(result)
+    }
+
+    #[instrument(skip(self))]
+    async fn update_saga(&self, saga: wallet::WalletSaga) -> Result<bool, database::Error> {
+        let id_str = saga.id.to_string();
+
+        // The saga.version has already been incremented by the caller, so we check
+        // for (saga.version - 1) as the expected version in the database.
+        let expected_version = saga.version.saturating_sub(1);
+
+        let write_txn = self.db.begin_write().map_err(Error::from)?;
+        let updated = {
+            let mut table = write_txn.open_table(SAGAS_TABLE).map_err(Error::from)?;
+
+            // Read existing saga to check version (optimistic locking)
+            let existing_saga_json = table
+                .get(id_str.as_str())
+                .map_err(Error::from)?
+                .map(|v| v.value().to_string());
+
+            match existing_saga_json {
+                Some(json) => {
+                    let existing_saga: wallet::WalletSaga =
+                        serde_json::from_str(&json).map_err(Error::from)?;
+
+                    // Check if version matches expected version
+                    if existing_saga.version != expected_version {
+                        // Version mismatch - another instance modified it
+                        false
+                    } else {
+                        // Version matches - safe to update
+                        let saga_json = serde_json::to_string(&saga).map_err(Error::from)?;
+                        table
+                            .insert(id_str.as_str(), saga_json.as_str())
+                            .map_err(Error::from)?;
+                        true
+                    }
+                }
+                None => {
+                    // Saga doesn't exist - can't update
+                    false
+                }
+            }
+        };
+        write_txn.commit().map_err(Error::from)?;
+        Ok(updated)
+    }
+
+    #[instrument(skip(self))]
+    async fn delete_saga(&self, id: &uuid::Uuid) -> Result<(), database::Error> {
+        let write_txn = self.db.begin_write().map_err(Error::from)?;
+        let id_str = id.to_string();
+        {
+            let mut table = write_txn.open_table(SAGAS_TABLE).map_err(Error::from)?;
+            table.remove(id_str.as_str()).map_err(Error::from)?;
+        }
+        write_txn.commit().map_err(Error::from)?;
+        Ok(())
+    }
+
+    #[instrument(skip(self))]
+    async fn get_incomplete_sagas(&self) -> Result<Vec<wallet::WalletSaga>, database::Error> {
+        let read_txn = self.db.begin_read().map_err(Error::from)?;
+        let table = read_txn.open_table(SAGAS_TABLE).map_err(Error::from)?;
+
+        let mut sagas: Vec<wallet::WalletSaga> = table
+            .iter()
+            .map_err(Error::from)?
+            .flatten()
+            .filter_map(|(_, saga_json)| {
+                serde_json::from_str::<wallet::WalletSaga>(saga_json.value()).ok()
+            })
+            .collect();
+
+        // Sort by created_at ascending (oldest first)
+        sagas.sort_by_key(|saga| saga.created_at);
+
+        Ok(sagas)
+    }
+
+    #[instrument(skip(self))]
+    async fn reserve_proofs(
+        &self,
+        ys: Vec<PublicKey>,
+        operation_id: &uuid::Uuid,
+    ) -> Result<(), database::Error> {
+        let write_txn = self.db.begin_write().map_err(Error::from)?;
+
+        {
+            let mut table = write_txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
+
+            for y in ys {
+                let y_bytes = y.to_bytes();
+
+                // Read the proof and convert to string immediately
+                let proof_json_str = {
+                    let proof_json_opt = table.get(y_bytes.as_slice()).map_err(Error::from)?;
+                    proof_json_opt.map(|proof_json| proof_json.value().to_string())
+                };
+
+                let Some(proof_json_str) = proof_json_str else {
+                    return Err(database::Error::ProofNotUnspent);
+                };
+
+                let mut proof: ProofInfo =
+                    serde_json::from_str(&proof_json_str).map_err(Error::from)?;
+
+                if proof.state != State::Unspent {
+                    return Err(database::Error::ProofNotUnspent);
+                }
+
+                proof.state = State::Reserved;
+                proof.used_by_operation = Some(*operation_id);
+
+                let updated_json = serde_json::to_string(&proof).map_err(Error::from)?;
+                table
+                    .insert(y_bytes.as_slice(), updated_json.as_str())
+                    .map_err(Error::from)?;
+            }
+        }
+
+        write_txn.commit().map_err(Error::from)?;
+        Ok(())
+    }
+
+    #[instrument(skip(self))]
+    async fn release_proofs(&self, operation_id: &uuid::Uuid) -> Result<(), database::Error> {
+        let write_txn = self.db.begin_write().map_err(Error::from)?;
+
+        {
+            let mut table = write_txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
+
+            // Collect all proofs first to avoid borrowing issues
+            let all_proofs: Vec<(Vec<u8>, ProofInfo)> = table
+                .iter()
+                .map_err(Error::from)?
+                .flatten()
+                .filter_map(|(y, proof_json)| {
+                    let proof: ProofInfo = serde_json::from_str(proof_json.value()).ok()?;
+                    Some((y.value().to_vec(), proof))
+                })
+                .collect();
+
+            // Now update proofs that match the operation_id
+            for (y_bytes, mut proof) in all_proofs {
+                if proof.used_by_operation == Some(*operation_id) {
+                    proof.state = State::Unspent;
+                    proof.used_by_operation = None;
+
+                    let updated_json = serde_json::to_string(&proof).map_err(Error::from)?;
+                    table
+                        .insert(y_bytes.as_slice(), updated_json.as_str())
+                        .map_err(Error::from)?;
+                }
+            }
+        }
+
+        write_txn.commit().map_err(Error::from)?;
+        Ok(())
+    }
+
+    #[instrument(skip(self))]
+    async fn get_reserved_proofs(
+        &self,
+        operation_id: &uuid::Uuid,
+    ) -> Result<Vec<ProofInfo>, database::Error> {
+        let read_txn = self.db.begin_read().map_err(Error::from)?;
+        let table = read_txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
+
+        let proofs: Vec<ProofInfo> = table
+            .iter()
+            .map_err(Error::from)?
+            .flatten()
+            .filter_map(|(_, proof_json)| {
+                serde_json::from_str::<ProofInfo>(proof_json.value()).ok()
+            })
+            .filter(|proof| proof.used_by_operation == Some(*operation_id))
+            .collect();
+
+        Ok(proofs)
+    }
+
+    #[instrument(skip(self))]
+    async fn reserve_melt_quote(
+        &self,
+        quote_id: &str,
+        operation_id: &uuid::Uuid,
+    ) -> Result<(), database::Error> {
+        let write_txn = self.db.begin_write().map_err(Error::from)?;
+        let operation_id_str = operation_id.to_string();
+
+        {
+            let mut table = write_txn
+                .open_table(MELT_QUOTES_TABLE)
+                .map_err(Error::from)?;
+
+            // Read existing quote
+            let quote_json = table
+                .get(quote_id)
+                .map_err(Error::from)?
+                .map(|v| v.value().to_string());
+
+            match quote_json {
+                Some(json) => {
+                    let mut quote: wallet::MeltQuote =
+                        serde_json::from_str(&json).map_err(Error::from)?;
+
+                    // Check if already reserved by another operation
+                    if quote.used_by_operation.is_some() {
+                        return Err(database::Error::QuoteAlreadyInUse);
+                    }
+
+                    // Reserve the quote
+                    quote.used_by_operation = Some(operation_id_str);
+                    let updated_json = serde_json::to_string(&quote).map_err(Error::from)?;
+                    table
+                        .insert(quote_id, updated_json.as_str())
+                        .map_err(Error::from)?;
+                }
+                None => {
+                    return Err(database::Error::UnknownQuote);
+                }
+            }
+        }
+
+        write_txn.commit().map_err(Error::from)?;
+        Ok(())
+    }
+
+    #[instrument(skip(self))]
+    async fn release_melt_quote(&self, operation_id: &uuid::Uuid) -> Result<(), database::Error> {
+        let write_txn = self.db.begin_write().map_err(Error::from)?;
+        let operation_id_str = operation_id.to_string();
+
+        {
+            let mut table = write_txn
+                .open_table(MELT_QUOTES_TABLE)
+                .map_err(Error::from)?;
+
+            // Collect all quotes first to avoid borrowing issues
+            let all_quotes: Vec<(String, wallet::MeltQuote)> = table
+                .iter()
+                .map_err(Error::from)?
+                .flatten()
+                .filter_map(|(id, quote_json)| {
+                    let quote: wallet::MeltQuote = serde_json::from_str(quote_json.value()).ok()?;
+                    Some((id.value().to_string(), quote))
+                })
+                .collect();
+
+            // Update quotes that match the operation_id
+            for (quote_id, mut quote) in all_quotes {
+                if quote.used_by_operation.as_deref() == Some(&operation_id_str) {
+                    quote.used_by_operation = None;
+                    let updated_json = serde_json::to_string(&quote).map_err(Error::from)?;
+                    table
+                        .insert(quote_id.as_str(), updated_json.as_str())
+                        .map_err(Error::from)?;
+                }
+            }
+        }
+
+        write_txn.commit().map_err(Error::from)?;
+        Ok(())
+    }
+
+    #[instrument(skip(self))]
+    async fn reserve_mint_quote(
+        &self,
+        quote_id: &str,
+        operation_id: &uuid::Uuid,
+    ) -> Result<(), database::Error> {
+        let write_txn = self.db.begin_write().map_err(Error::from)?;
+        let operation_id_str = operation_id.to_string();
+
+        {
+            let mut table = write_txn
+                .open_table(MINT_QUOTES_TABLE)
+                .map_err(Error::from)?;
+
+            // Read existing quote
+            let quote_json = table
+                .get(quote_id)
+                .map_err(Error::from)?
+                .map(|v| v.value().to_string());
+
+            match quote_json {
+                Some(json) => {
+                    let mut quote: MintQuote = serde_json::from_str(&json).map_err(Error::from)?;
+
+                    // Check if already reserved by another operation
+                    if quote.used_by_operation.is_some() {
+                        return Err(database::Error::QuoteAlreadyInUse);
+                    }
+
+                    // Reserve the quote
+                    quote.used_by_operation = Some(operation_id_str);
+                    let updated_json = serde_json::to_string(&quote).map_err(Error::from)?;
+                    table
+                        .insert(quote_id, updated_json.as_str())
+                        .map_err(Error::from)?;
+                }
+                None => {
+                    return Err(database::Error::UnknownQuote);
+                }
+            }
+        }
+
+        write_txn.commit().map_err(Error::from)?;
+        Ok(())
+    }
+
+    #[instrument(skip(self))]
+    async fn release_mint_quote(&self, operation_id: &uuid::Uuid) -> Result<(), database::Error> {
+        let write_txn = self.db.begin_write().map_err(Error::from)?;
+        let operation_id_str = operation_id.to_string();
+
+        {
+            let mut table = write_txn
+                .open_table(MINT_QUOTES_TABLE)
+                .map_err(Error::from)?;
+
+            // Collect all quotes first to avoid borrowing issues
+            let all_quotes: Vec<(String, MintQuote)> = table
+                .iter()
+                .map_err(Error::from)?
+                .flatten()
+                .filter_map(|(id, quote_json)| {
+                    let quote: MintQuote = serde_json::from_str(quote_json.value()).ok()?;
+                    Some((id.value().to_string(), quote))
+                })
+                .collect();
+
+            // Update quotes that match the operation_id
+            for (quote_id, mut quote) in all_quotes {
+                if quote.used_by_operation.as_deref() == Some(&operation_id_str) {
+                    quote.used_by_operation = None;
+                    let updated_json = serde_json::to_string(&quote).map_err(Error::from)?;
+                    table
+                        .insert(quote_id.as_str(), updated_json.as_str())
+                        .map_err(Error::from)?;
+                }
+            }
+        }
+
+        write_txn.commit().map_err(Error::from)?;
+        Ok(())
+    }
+
+    #[instrument(skip(self, value))]
+    async fn kv_write(
+        &self,
+        primary_namespace: &str,
+        secondary_namespace: &str,
+        key: &str,
+        value: &[u8],
+    ) -> Result<(), database::Error> {
+        // Validate parameters according to KV store requirements
+        validate_kvstore_params(primary_namespace, secondary_namespace, Some(key))?;
+
+        let write_txn = self.db.begin_write().map_err(Error::from)?;
+        {
+            let mut table = write_txn.open_table(KV_STORE_TABLE).map_err(Error::from)?;
+            table
+                .insert((primary_namespace, secondary_namespace, key), value)
+                .map_err(Error::from)?;
+        }
+        write_txn.commit().map_err(Error::from)?;
+
+        Ok(())
+    }
 
     #[instrument(skip(self))]
     async fn kv_read(
@@ -998,29 +1448,6 @@ impl WalletDatabase<database::Error> for WalletRedbDatabase {
         Ok(keys)
     }
 
-    #[instrument(skip(self, value))]
-    async fn kv_write(
-        &self,
-        primary_namespace: &str,
-        secondary_namespace: &str,
-        key: &str,
-        value: &[u8],
-    ) -> Result<(), database::Error> {
-        // Validate parameters according to KV store requirements
-        validate_kvstore_params(primary_namespace, secondary_namespace, Some(key))?;
-
-        let write_txn = self.db.begin_write().map_err(Error::from)?;
-        {
-            let mut table = write_txn.open_table(KV_STORE_TABLE).map_err(Error::from)?;
-            table
-                .insert((primary_namespace, secondary_namespace, key), value)
-                .map_err(Error::from)?;
-        }
-        write_txn.commit().map_err(Error::from)?;
-
-        Ok(())
-    }
-
     #[instrument(skip(self))]
     async fn kv_remove(
         &self,

+ 45 - 0
crates/cdk-sql-common/src/wallet/migrations/postgres/20251228000000_add_wallet_operations.sql

@@ -0,0 +1,45 @@
+-- Migration to add wallet sagas table and proof operation tracking
+
+-- Create wallet_sagas table with version for optimistic locking
+CREATE TABLE IF NOT EXISTS wallet_sagas (
+    id TEXT PRIMARY KEY,
+    kind TEXT CHECK (kind IN ('send', 'receive', 'swap', 'mint', 'melt')) NOT NULL,
+    state TEXT NOT NULL,
+    amount BIGINT NOT NULL,
+    mint_url TEXT NOT NULL,
+    unit TEXT NOT NULL,
+    quote_id TEXT,
+    created_at BIGINT NOT NULL,
+    updated_at BIGINT NOT NULL,
+    data TEXT NOT NULL,
+    version INTEGER NOT NULL DEFAULT 0
+);
+
+-- Create indexes for efficient queries
+CREATE INDEX IF NOT EXISTS wallet_sagas_mint_url_index ON wallet_sagas(mint_url);
+CREATE INDEX IF NOT EXISTS wallet_sagas_kind_index ON wallet_sagas(kind);
+CREATE INDEX IF NOT EXISTS wallet_sagas_created_at_index ON wallet_sagas(created_at);
+
+-- Add operation tracking columns to proof table
+ALTER TABLE proof ADD COLUMN IF NOT EXISTS used_by_operation TEXT;
+ALTER TABLE proof ADD COLUMN IF NOT EXISTS created_by_operation TEXT;
+
+-- Create index for efficient operation-based proof queries
+CREATE INDEX IF NOT EXISTS proof_used_by_operation_index ON proof(used_by_operation);
+CREATE INDEX IF NOT EXISTS proof_created_by_operation_index ON proof(created_by_operation);
+
+-- Add operation tracking to quote tables to prevent concurrent operations on same quote
+ALTER TABLE melt_quote ADD COLUMN IF NOT EXISTS used_by_operation TEXT;
+ALTER TABLE mint_quote ADD COLUMN IF NOT EXISTS used_by_operation TEXT;
+
+-- Create indexes for efficient operation-based quote queries
+CREATE INDEX IF NOT EXISTS melt_quote_used_by_operation_index ON melt_quote(used_by_operation);
+CREATE INDEX IF NOT EXISTS mint_quote_used_by_operation_index ON mint_quote(used_by_operation);
+
+-- Add saga_id to transactions to link persistent history with lifecycle state
+ALTER TABLE transactions ADD COLUMN IF NOT EXISTS saga_id TEXT;
+CREATE INDEX IF NOT EXISTS transactions_saga_id_index ON transactions(saga_id);
+
+-- Add version column to quotes for optimistic locking
+ALTER TABLE melt_quote ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE mint_quote ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 0;

+ 45 - 0
crates/cdk-sql-common/src/wallet/migrations/sqlite/20251228000000_add_wallet_operations.sql

@@ -0,0 +1,45 @@
+-- Migration to add wallet sagas table and proof operation tracking
+
+-- Create wallet_sagas table with version for optimistic locking
+CREATE TABLE IF NOT EXISTS wallet_sagas (
+    id TEXT PRIMARY KEY,
+    kind TEXT CHECK (kind IN ('send', 'receive', 'swap', 'mint', 'melt')) NOT NULL,
+    state TEXT NOT NULL,
+    amount INTEGER NOT NULL,
+    mint_url TEXT NOT NULL,
+    unit TEXT NOT NULL,
+    quote_id TEXT,
+    created_at INTEGER NOT NULL,
+    updated_at INTEGER NOT NULL,
+    data TEXT NOT NULL,
+    version INTEGER NOT NULL DEFAULT 0
+);
+
+-- Create indexes for efficient queries
+CREATE INDEX IF NOT EXISTS wallet_sagas_mint_url_index ON wallet_sagas(mint_url);
+CREATE INDEX IF NOT EXISTS wallet_sagas_kind_index ON wallet_sagas(kind);
+CREATE INDEX IF NOT EXISTS wallet_sagas_created_at_index ON wallet_sagas(created_at);
+
+-- Add operation tracking columns to proof table
+ALTER TABLE proof ADD COLUMN used_by_operation TEXT;
+ALTER TABLE proof ADD COLUMN created_by_operation TEXT;
+
+-- Create index for efficient operation-based proof queries
+CREATE INDEX IF NOT EXISTS proof_used_by_operation_index ON proof(used_by_operation);
+CREATE INDEX IF NOT EXISTS proof_created_by_operation_index ON proof(created_by_operation);
+
+-- Add operation tracking to quote tables to prevent concurrent operations on same quote
+ALTER TABLE melt_quote ADD COLUMN used_by_operation TEXT;
+ALTER TABLE mint_quote ADD COLUMN used_by_operation TEXT;
+
+-- Create indexes for efficient operation-based quote queries
+CREATE INDEX IF NOT EXISTS melt_quote_used_by_operation_index ON melt_quote(used_by_operation);
+CREATE INDEX IF NOT EXISTS mint_quote_used_by_operation_index ON mint_quote(used_by_operation);
+
+-- Add saga_id to transactions to link persistent history with lifecycle state
+ALTER TABLE transactions ADD COLUMN saga_id TEXT;
+CREATE INDEX IF NOT EXISTS transactions_saga_id_index ON transactions(saga_id);
+
+-- Add version column to quotes for optimistic locking
+ALTER TABLE melt_quote ADD COLUMN version INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE mint_quote ADD COLUMN version INTEGER NOT NULL DEFAULT 0;

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 541 - 240
crates/cdk-sql-common/src/wallet/mod.rs


+ 12 - 2
crates/cdk-sqlite/src/wallet/mod.rs

@@ -57,9 +57,9 @@ mod tests {
 
     #[tokio::test]
     async fn test_proof_with_dleq() {
-        use cdk_common::common::ProofInfo;
         use cdk_common::mint_url::MintUrl;
         use cdk_common::nuts::{CurrencyUnit, Id, Proof, PublicKey, SecretKey};
+        use cdk_common::wallet::ProofInfo;
         use cdk_common::Amount;
 
         // Create a temporary database
@@ -177,6 +177,8 @@ mod tests {
                 payment_method: payment_method.clone(),
                 amount_issued: Amount::from(0),
                 amount_paid: Amount::from(0),
+                used_by_operation: None,
+                version: 0,
             };
 
             // Store the quote
@@ -192,9 +194,9 @@ mod tests {
 
     #[tokio::test]
     async fn test_get_proofs_by_ys() {
-        use cdk_common::common::ProofInfo;
         use cdk_common::mint_url::MintUrl;
         use cdk_common::nuts::{CurrencyUnit, Id, Proof, SecretKey};
+        use cdk_common::wallet::ProofInfo;
         use cdk_common::Amount;
 
         // Create a temporary database
@@ -315,6 +317,8 @@ mod tests {
             payment_method: PaymentMethod::Known(KnownMethod::Bolt11),
             amount_issued: Amount::from(100),
             amount_paid: Amount::from(100),
+            used_by_operation: None,
+            version: 0,
         };
 
         // Quote 2: Paid but not yet issued (should be returned - has pending balance)
@@ -330,6 +334,8 @@ mod tests {
             payment_method: PaymentMethod::Known(KnownMethod::Bolt11),
             amount_issued: Amount::from(0),
             amount_paid: Amount::from(100),
+            used_by_operation: None,
+            version: 0,
         };
 
         // Quote 3: Bolt12 quote with no balance (should be returned - bolt12 is reusable)
@@ -345,6 +351,8 @@ mod tests {
             payment_method: PaymentMethod::Known(KnownMethod::Bolt12),
             amount_issued: Amount::from(0),
             amount_paid: Amount::from(0),
+            used_by_operation: None,
+            version: 0,
         };
 
         // Quote 4: Unpaid bolt11 quote (should be returned - wallet needs to check with mint)
@@ -360,6 +368,8 @@ mod tests {
             payment_method: PaymentMethod::Known(KnownMethod::Bolt11),
             amount_issued: Amount::from(0),
             amount_paid: Amount::from(0),
+            used_by_operation: None,
+            version: 0,
         };
 
         // Add all quotes to the database

+ 16 - 0
crates/cdk/Cargo.toml

@@ -168,6 +168,22 @@ required-features = ["npubcash"]
 name = "configure_wallet"
 required-features = ["wallet"]
 
+[[example]]
+name = "receive-token"
+required-features = ["wallet"]
+
+[[example]]
+name = "multi-mint-wallet"
+required-features = ["wallet"]
+
+[[example]]
+name = "restore-wallet"
+required-features = ["wallet"]
+
+[[example]]
+name = "revoke_send"
+required-features = ["wallet"]
+
 [dev-dependencies]
 rand.workspace = true
 cdk-sqlite.workspace = true

+ 2 - 2
crates/cdk/README.md

@@ -76,12 +76,12 @@ async fn main() {
 
         let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None).unwrap();
 
-        let quote = wallet.mint_quote(amount, None).await.unwrap();
+        let quote = wallet.mint_bolt11_quote(amount, None).await.unwrap();
 
         println!("Pay request: {}", quote.request);
 
         loop {
-            let status = wallet.mint_quote_state(&quote.id).await.unwrap();
+            let status = wallet.refresh_mint_quote_status(&quote.id).await.unwrap();
 
             if status.state == MintQuoteState::Paid {
                 break;

+ 10 - 3
crates/cdk/examples/auth_wallet.rs

@@ -1,8 +1,10 @@
+#![allow(missing_docs)]
+
 use std::sync::Arc;
 use std::time::Duration;
 
 use cdk::error::Error;
-use cdk::nuts::CurrencyUnit;
+use cdk::nuts::{CurrencyUnit, PaymentMethod};
 use cdk::wallet::{SendOptions, Wallet};
 use cdk::{Amount, OidcClient};
 use cdk_common::amount::SplitTarget;
@@ -43,7 +45,9 @@ async fn main() -> Result<(), Error> {
         .expect("could not get mint info");
 
     // Request a mint quote from the wallet
-    let quote = wallet.mint_quote(amount, None).await;
+    let quote = wallet
+        .mint_quote(PaymentMethod::BOLT11, Some(amount), None, None)
+        .await;
 
     println!("Minting nuts ... {:?}", quote);
 
@@ -58,7 +62,10 @@ async fn main() -> Result<(), Error> {
         .await
         .expect("Could not mint blind auth");
 
-    let quote = wallet.mint_quote(amount, None).await.unwrap();
+    let quote = wallet
+        .mint_quote(PaymentMethod::BOLT11, Some(amount), None, None)
+        .await
+        .unwrap();
     let proofs = wallet
         .wait_and_mint_quote(quote, SplitTarget::default(), None, Duration::from_secs(10))
         .await

+ 33 - 16
crates/cdk/examples/bip353.rs

@@ -26,7 +26,7 @@ use std::time::Duration;
 
 use cdk::amount::SplitTarget;
 use cdk::nuts::nut00::ProofsMethods;
-use cdk::nuts::{CurrencyUnit, MintQuoteState};
+use cdk::nuts::{CurrencyUnit, PaymentMethod};
 use cdk::wallet::Wallet;
 use cdk::Amount;
 use cdk_sqlite::wallet::memory;
@@ -60,7 +60,9 @@ async fn main() -> anyhow::Result<()> {
 
     // First, we need to fund the wallet
     println!("Requesting mint quote for {} sats...", initial_amount);
-    let mint_quote = wallet.mint_quote(initial_amount, None).await?;
+    let mint_quote = wallet
+        .mint_quote(PaymentMethod::BOLT12, Some(initial_amount), None, None)
+        .await?;
     println!(
         "Pay this invoice to fund the wallet: {}",
         mint_quote.request
@@ -75,13 +77,13 @@ async fn main() -> anyhow::Result<()> {
     let start = std::time::Instant::now();
 
     while start.elapsed() < timeout {
-        let status = wallet.mint_quote_state(&mint_quote.id).await?;
+        let status = wallet.refresh_mint_quote_status(&mint_quote.id).await?;
 
-        if status.state == MintQuoteState::Paid {
+        if status.amount_paid >= initial_amount {
             break;
         }
 
-        println!("Quote state: {} (waiting...)", status.state);
+        println!("Amount paid: {} (waiting...)", status.amount_paid);
         sleep(Duration::from_secs(2)).await;
     }
 
@@ -112,20 +114,35 @@ async fn main() -> anyhow::Result<()> {
             println!("  Fee Reserve: {} sats", melt_quote.fee_reserve);
             println!("  State: {}", melt_quote.state);
 
-            // Execute the payment
-            match wallet.melt(&melt_quote.id).await {
-                Ok(melt_result) => {
-                    println!("BIP-353 payment successful!");
-                    println!("  State: {}", melt_result.state);
-                    println!("  Amount paid: {} sats", melt_result.amount);
-                    println!("  Fee paid: {} sats", melt_result.fee_paid);
-
-                    if let Some(preimage) = melt_result.preimage {
-                        println!("  Payment preimage: {}", preimage);
+            // Prepare the payment - shows fees before confirming
+            match wallet
+                .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+                .await
+            {
+                Ok(prepared) => {
+                    println!("Prepared melt:");
+                    println!("  Amount: {} sats", prepared.amount());
+                    println!("  Total Fee: {} sats", prepared.total_fee());
+
+                    // Execute the payment
+                    match prepared.confirm().await {
+                        Ok(confirmed) => {
+                            println!("BIP-353 payment successful!");
+                            println!("  State: {:?}", confirmed.state());
+                            println!("  Amount paid: {} sats", confirmed.amount());
+                            println!("  Fee paid: {} sats", confirmed.fee_paid());
+
+                            if let Some(preimage) = confirmed.payment_proof() {
+                                println!("  Payment preimage: {}", preimage);
+                            }
+                        }
+                        Err(e) => {
+                            println!("BIP-353 payment failed: {}", e);
+                        }
                     }
                 }
                 Err(e) => {
-                    println!("BIP-353 payment failed: {}", e);
+                    println!("Failed to prepare melt: {}", e);
                 }
             }
         }

+ 60 - 26
crates/cdk/examples/human_readable_payment.rs

@@ -36,7 +36,7 @@ use std::time::Duration;
 
 use cdk::amount::SplitTarget;
 use cdk::nuts::nut00::ProofsMethods;
-use cdk::nuts::CurrencyUnit;
+use cdk::nuts::{CurrencyUnit, PaymentMethod};
 use cdk::wallet::Wallet;
 use cdk::Amount;
 use cdk_sqlite::wallet::memory;
@@ -71,7 +71,9 @@ async fn main() -> anyhow::Result<()> {
 
     // First, we need to fund the wallet
     println!("Requesting mint quote for {} sats...", initial_amount);
-    let mint_quote = wallet.mint_quote(initial_amount, None).await?;
+    let mint_quote = wallet
+        .mint_quote(PaymentMethod::BOLT12, Some(initial_amount), None, None)
+        .await?;
     println!(
         "Pay this invoice to fund the wallet:\n{}",
         mint_quote.request
@@ -123,21 +125,37 @@ async fn main() -> anyhow::Result<()> {
             println!("  State: {}", melt_quote.state);
             println!("  Payment Method: {}", melt_quote.payment_method);
 
-            // Execute the payment
-            println!("\nExecuting payment...");
-            match wallet.melt(&melt_quote.id).await {
-                Ok(melt_result) => {
-                    println!("✓ BIP-353 payment successful!");
-                    println!("  State: {}", melt_result.state);
-                    println!("  Amount paid: {} sats", melt_result.amount);
-                    println!("  Fee paid: {} sats", melt_result.fee_paid);
-
-                    if let Some(preimage) = melt_result.preimage {
-                        println!("  Payment preimage: {}", preimage);
+            // Prepare the payment - shows fees before confirming
+            println!("\nPreparing payment...");
+            match wallet
+                .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+                .await
+            {
+                Ok(prepared) => {
+                    println!("✓ Prepared melt:");
+                    println!("  Amount: {} sats", prepared.amount());
+                    println!("  Total Fee: {} sats", prepared.total_fee());
+
+                    // Execute the payment
+                    println!("\nExecuting payment...");
+                    match prepared.confirm().await {
+                        Ok(confirmed) => {
+                            println!("✓ BIP-353 payment successful!");
+                            println!("  State: {:?}", confirmed.state());
+                            println!("  Amount paid: {} sats", confirmed.amount());
+                            println!("  Fee paid: {} sats", confirmed.fee_paid());
+
+                            if let Some(preimage) = confirmed.payment_proof() {
+                                println!("  Payment preimage: {}", preimage);
+                            }
+                        }
+                        Err(e) => {
+                            println!("✗ BIP-353 payment failed: {}", e);
+                        }
                     }
                 }
                 Err(e) => {
-                    println!("✗ BIP-353 payment failed: {}", e);
+                    println!("✗ Failed to prepare melt: {}", e);
                 }
             }
         }
@@ -184,21 +202,37 @@ async fn main() -> anyhow::Result<()> {
             println!("  State: {}", melt_quote.state);
             println!("  Payment Method: {}", melt_quote.payment_method);
 
-            // Execute the payment
-            println!("\nExecuting payment...");
-            match wallet.melt(&melt_quote.id).await {
-                Ok(melt_result) => {
-                    println!("✓ Lightning Address payment successful!");
-                    println!("  State: {}", melt_result.state);
-                    println!("  Amount paid: {} sats", melt_result.amount);
-                    println!("  Fee paid: {} sats", melt_result.fee_paid);
-
-                    if let Some(preimage) = melt_result.preimage {
-                        println!("  Payment preimage: {}", preimage);
+            // Prepare the payment - shows fees before confirming
+            println!("\nPreparing payment...");
+            match wallet
+                .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+                .await
+            {
+                Ok(prepared) => {
+                    println!("✓ Prepared melt:");
+                    println!("  Amount: {} sats", prepared.amount());
+                    println!("  Total Fee: {} sats", prepared.total_fee());
+
+                    // Execute the payment
+                    println!("\nExecuting payment...");
+                    match prepared.confirm().await {
+                        Ok(confirmed) => {
+                            println!("✓ Lightning Address payment successful!");
+                            println!("  State: {:?}", confirmed.state());
+                            println!("  Amount paid: {} sats", confirmed.amount());
+                            println!("  Fee paid: {} sats", confirmed.fee_paid());
+
+                            if let Some(preimage) = confirmed.payment_proof() {
+                                println!("  Payment preimage: {}", preimage);
+                            }
+                        }
+                        Err(e) => {
+                            println!("✗ Lightning Address payment failed: {}", e);
+                        }
                     }
                 }
                 Err(e) => {
-                    println!("✗ Lightning Address payment failed: {}", e);
+                    println!("✗ Failed to prepare melt: {}", e);
                 }
             }
         }

+ 27 - 5
crates/cdk/examples/melt-token.rs

@@ -1,3 +1,5 @@
+#![allow(missing_docs)]
+
 use std::sync::Arc;
 use std::time::Duration;
 
@@ -6,7 +8,7 @@ use bitcoin::hex::prelude::FromHex;
 use bitcoin::secp256k1::Secp256k1;
 use cdk::error::Error;
 use cdk::nuts::nut00::ProofsMethods;
-use cdk::nuts::{CurrencyUnit, SecretKey};
+use cdk::nuts::{CurrencyUnit, PaymentMethod, SecretKey};
 use cdk::wallet::Wallet;
 use cdk::Amount;
 use cdk_sqlite::wallet::memory;
@@ -29,7 +31,9 @@ async fn main() -> Result<(), Error> {
     // Create a new wallet
     let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None)?;
 
-    let quote = wallet.mint_quote(amount, None).await?;
+    let quote = wallet
+        .mint_quote(PaymentMethod::BOLT11, Some(amount), None, None)
+        .await?;
     let proofs = wallet
         .wait_and_mint_quote(
             quote,
@@ -64,14 +68,32 @@ async fn main() -> Result<(), Error> {
         .to_string();
     println!("Invoice to be paid: {}", invoice_to_be_paid);
 
-    let melt_quote = wallet.melt_quote(invoice_to_be_paid, None).await?;
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice_to_be_paid, None, None)
+        .await?;
     println!(
         "Melt quote: {} {} {:?}",
         melt_quote.amount, melt_quote.state, melt_quote,
     );
 
-    let melted = wallet.melt(&melt_quote.id).await?;
-    println!("Melted: {:?}", melted);
+    // Prepare the melt - this shows fees before confirming
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await?;
+    println!(
+        "Prepared melt - Amount: {}, Total Fee: {}",
+        prepared.amount(),
+        prepared.total_fee()
+    );
+
+    // Confirm the melt to execute the payment
+    let confirmed = prepared.confirm().await?;
+    println!(
+        "Melted: state={:?}, amount={}, fee={}",
+        confirmed.state(),
+        confirmed.amount(),
+        confirmed.fee_paid()
+    );
 
     Ok(())
 }

+ 12 - 4
crates/cdk/examples/mint-token-bolt12-with-custom-http.rs

@@ -1,3 +1,5 @@
+#![allow(missing_docs)]
+
 use std::str::FromStr;
 use std::sync::Arc;
 use std::time::Duration;
@@ -8,7 +10,7 @@ use cdk::nuts::CurrencyUnit;
 use cdk::wallet::{BaseHttpClient, HttpTransport, SendOptions, WalletBuilder};
 use cdk::{Amount, StreamExt};
 use cdk_common::mint_url::MintUrl;
-use cdk_common::AuthToken;
+use cdk_common::{AuthToken, PaymentMethod};
 use cdk_sqlite::wallet::memory;
 use rand::random;
 use serde::de::DeserializeOwned;
@@ -130,9 +132,15 @@ async fn main() -> Result<(), Error> {
         .build()?;
 
     let quotes = vec![
-        wallet.mint_bolt12_quote(None, None).await?,
-        wallet.mint_bolt12_quote(None, None).await?,
-        wallet.mint_bolt12_quote(None, None).await?,
+        wallet
+            .mint_quote(PaymentMethod::BOLT12, None, None, None)
+            .await?,
+        wallet
+            .mint_quote(PaymentMethod::BOLT12, None, None, None)
+            .await?,
+        wallet
+            .mint_quote(PaymentMethod::BOLT12, None, None, None)
+            .await?,
     ];
 
     let mut stream = wallet.mints_proof_stream(quotes, Default::default(), None);

+ 12 - 4
crates/cdk/examples/mint-token-bolt12-with-stream.rs

@@ -1,8 +1,10 @@
+#![allow(missing_docs)]
+
 use std::sync::Arc;
 
 use cdk::error::Error;
 use cdk::nuts::nut00::ProofsMethods;
-use cdk::nuts::CurrencyUnit;
+use cdk::nuts::{CurrencyUnit, PaymentMethod};
 use cdk::wallet::{SendOptions, Wallet};
 use cdk::{Amount, StreamExt};
 use cdk_sqlite::wallet::memory;
@@ -35,9 +37,15 @@ async fn main() -> Result<(), Error> {
     let wallet = Wallet::new(mint_url, unit, localstore, seed, None)?;
 
     let quotes = vec![
-        wallet.mint_bolt12_quote(None, None).await?,
-        wallet.mint_bolt12_quote(None, None).await?,
-        wallet.mint_bolt12_quote(None, None).await?,
+        wallet
+            .mint_quote(PaymentMethod::BOLT12, None, None, None)
+            .await?,
+        wallet
+            .mint_quote(PaymentMethod::BOLT12, None, None, None)
+            .await?,
+        wallet
+            .mint_quote(PaymentMethod::BOLT12, None, None, None)
+            .await?,
     ];
 
     let mut stream = wallet.mints_proof_stream(quotes, Default::default(), None);

+ 6 - 2
crates/cdk/examples/mint-token-bolt12.rs

@@ -1,9 +1,11 @@
+#![allow(missing_docs)]
+
 use std::sync::Arc;
 use std::time::Duration;
 
 use cdk::error::Error;
 use cdk::nuts::nut00::ProofsMethods;
-use cdk::nuts::CurrencyUnit;
+use cdk::nuts::{CurrencyUnit, PaymentMethod};
 use cdk::wallet::{SendOptions, Wallet};
 use cdk::Amount;
 use cdk_sqlite::wallet::memory;
@@ -35,7 +37,9 @@ async fn main() -> Result<(), Error> {
     // Create a new wallet
     let wallet = Wallet::new(mint_url, unit, localstore, seed, None)?;
 
-    let quote = wallet.mint_bolt12_quote(None, None).await?;
+    let quote = wallet
+        .mint_quote(PaymentMethod::BOLT12, None, None, None)
+        .await?;
     let proofs = wallet
         .wait_and_mint_quote(
             quote,

+ 6 - 2
crates/cdk/examples/mint-token.rs

@@ -1,9 +1,11 @@
+#![allow(missing_docs)]
+
 use std::sync::Arc;
 use std::time::Duration;
 
 use cdk::error::Error;
 use cdk::nuts::nut00::ProofsMethods;
-use cdk::nuts::CurrencyUnit;
+use cdk::nuts::{CurrencyUnit, PaymentMethod};
 use cdk::wallet::{SendOptions, Wallet};
 use cdk::Amount;
 use cdk_sqlite::wallet::memory;
@@ -35,7 +37,9 @@ async fn main() -> Result<(), Error> {
     // Create a new wallet
     let wallet = Wallet::new(mint_url, unit, localstore, seed, None)?;
 
-    let quote = wallet.mint_quote(amount, None).await?;
+    let quote = wallet
+        .mint_quote(PaymentMethod::BOLT11, Some(amount), None, None)
+        .await?;
     let proofs = wallet
         .wait_and_mint_quote(
             quote,

+ 166 - 0
crates/cdk/examples/multi-mint-wallet.rs

@@ -0,0 +1,166 @@
+#![allow(missing_docs)]
+
+use std::collections::HashMap;
+use std::str::FromStr;
+use std::sync::Arc;
+use std::time::Duration;
+
+use bip39::Mnemonic;
+use cdk::amount::SplitTarget;
+use cdk::mint_url::MintUrl;
+use cdk::nuts::nut00::ProofsMethods;
+use cdk::nuts::CurrencyUnit;
+use cdk::wallet::multi_mint_wallet::MultiMintWallet;
+use cdk::wallet::{MultiMintReceiveOptions, SendOptions};
+use cdk::Amount;
+use cdk_fake_wallet::create_fake_invoice;
+use cdk_sqlite::wallet::memory;
+
+/// This example demonstrates the MultiMintWallet API for managing multiple mints.
+///
+/// It shows:
+/// - Creating a MultiMintWallet
+/// - Adding a mint
+/// - Minting proofs
+/// - Sending tokens
+/// - Receiving tokens
+/// - Melting (paying Lightning invoices)
+/// - Querying balances
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    // Configuration
+    let mint_url = MintUrl::from_str("https://fake.thesimplekid.dev")?;
+    let unit = CurrencyUnit::Sat;
+
+    // Generate a seed from a mnemonic (in production, store this securely!)
+    let mnemonic = Mnemonic::generate(12)?;
+    let seed = mnemonic.to_seed_normalized("");
+    println!("Generated mnemonic (save this!): {}", mnemonic);
+
+    // Create the MultiMintWallet
+    let localstore = Arc::new(memory::empty().await?);
+    let wallet = MultiMintWallet::new(localstore, seed, unit.clone()).await?;
+    println!("\nCreated MultiMintWallet");
+
+    // Add a mint to the wallet
+    wallet.add_mint(mint_url.clone()).await?;
+    println!("Added mint: {}", mint_url);
+
+    // ========================================
+    // MINT: Create proofs from Lightning invoice
+    // ========================================
+    let mint_amount = Amount::from(100);
+    println!("\n--- MINT ---");
+    println!("Creating mint quote for {} sats...", mint_amount);
+
+    let mint_quote = wallet.mint_quote(&mint_url, mint_amount, None).await?;
+    println!("Invoice to pay: {}", mint_quote.request);
+
+    // Wait for quote to be paid and mint proofs
+    // With the fake mint, this happens automatically
+    let proofs = wallet
+        .wait_for_mint_quote(
+            &mint_url,
+            &mint_quote.id,
+            SplitTarget::default(),
+            None,
+            Duration::from_secs(30),
+        )
+        .await?;
+
+    let minted_amount = proofs.total_amount()?;
+    println!("Minted {} sats", minted_amount);
+
+    // Check balance
+    let balance = wallet.total_balance().await?;
+    println!("Total balance: {} sats", balance);
+
+    // ========================================
+    // SEND: Create a token to send to someone
+    // ========================================
+    let send_amount = Amount::from(25);
+    println!("\n--- SEND ---");
+    println!("Preparing to send {} sats...", send_amount);
+
+    let prepared_send = wallet
+        .prepare_send(mint_url.clone(), send_amount, SendOptions::default())
+        .await?;
+    let token = prepared_send.confirm(None).await?;
+    println!("Token created:\n{}", token);
+
+    // Check balance after send
+    let balance = wallet.total_balance().await?;
+    println!("Balance after send: {} sats", balance);
+
+    // ========================================
+    // RECEIVE: Receive a token (using a second wallet)
+    // ========================================
+    println!("\n--- RECEIVE ---");
+
+    // Create a second wallet to receive the token
+    let receiver_seed = Mnemonic::generate(12)?.to_seed_normalized("");
+    let receiver_store = Arc::new(memory::empty().await?);
+    let receiver_wallet = MultiMintWallet::new(receiver_store, receiver_seed, unit).await?;
+
+    // Add the mint (or use allow_untrusted)
+    receiver_wallet.add_mint(mint_url.clone()).await?;
+
+    // Receive the token
+    let received = receiver_wallet
+        .receive(&token.to_string(), MultiMintReceiveOptions::default())
+        .await?;
+    println!("Receiver got {} sats", received);
+
+    // Check receiver balance
+    let receiver_balance = receiver_wallet.total_balance().await?;
+    println!("Receiver balance: {} sats", receiver_balance);
+
+    // ========================================
+    // MELT: Pay a Lightning invoice
+    // ========================================
+    let melt_amount_sats: u64 = 10;
+    println!("\n--- MELT ---");
+    println!("Creating invoice for {} sats to melt...", melt_amount_sats);
+
+    // Create a fake invoice (works with fake.thesimplekid.dev)
+    let invoice = create_fake_invoice(melt_amount_sats * 1000, "test melt".to_string());
+    println!("Invoice: {}", invoice);
+
+    // Create melt quote
+    let melt_quote = wallet
+        .melt_quote(&mint_url, invoice.to_string(), None)
+        .await?;
+    println!(
+        "Melt quote: {} sats + {} fee reserve",
+        melt_quote.amount, melt_quote.fee_reserve
+    );
+
+    // Prepare and execute melt
+    let prepared_melt = wallet
+        .prepare_melt(&mint_url, &melt_quote.id, HashMap::new())
+        .await?;
+    let melt_result = prepared_melt.confirm().await?;
+    println!("Melt completed! State: {:?}", melt_result.state());
+
+    // ========================================
+    // BALANCE: Query balances
+    // ========================================
+    println!("\n--- BALANCES ---");
+
+    let total = wallet.total_balance().await?;
+    println!("Total balance: {} sats", total);
+
+    let per_mint = wallet.get_balances().await?;
+    for (url, amount) in per_mint {
+        println!("  {}: {} sats", url, amount);
+    }
+
+    // List all mints
+    println!("\nMints in wallet:");
+    let wallets = wallet.get_wallets().await;
+    for w in wallets {
+        println!("  - {} ({})", w.mint_url, w.unit);
+    }
+
+    Ok(())
+}

+ 6 - 2
crates/cdk/examples/p2pk.rs

@@ -1,8 +1,10 @@
+#![allow(missing_docs)]
+
 use std::sync::Arc;
 use std::time::Duration;
 
 use cdk::error::Error;
-use cdk::nuts::{CurrencyUnit, SecretKey, SpendingConditions};
+use cdk::nuts::{CurrencyUnit, PaymentMethod, SecretKey, SpendingConditions};
 use cdk::wallet::{ReceiveOptions, SendOptions, Wallet};
 use cdk::Amount;
 use cdk_sqlite::wallet::memory;
@@ -34,7 +36,9 @@ async fn main() -> Result<(), Error> {
     // Create a new wallet
     let wallet = Wallet::new(mint_url, unit, localstore, seed, None).unwrap();
 
-    let quote = wallet.mint_quote(amount, None).await?;
+    let quote = wallet
+        .mint_quote(PaymentMethod::BOLT11, Some(amount), None, None)
+        .await?;
     let proofs = wallet
         .wait_and_mint_quote(
             quote,

+ 4 - 2
crates/cdk/examples/payment_request.rs

@@ -28,7 +28,7 @@ use std::time::Duration;
 
 use anyhow::anyhow;
 use cdk::amount::SplitTarget;
-use cdk::nuts::CurrencyUnit;
+use cdk::nuts::{CurrencyUnit, PaymentMethod};
 use cdk::wallet::multi_mint_wallet::MultiMintWallet;
 use cdk::wallet::payment_request::CreateRequestParams;
 use cdk_sqlite::wallet::memory;
@@ -64,7 +64,9 @@ async fn main() -> anyhow::Result<()> {
         .get_wallet(&mint_url.parse()?)
         .await
         .ok_or_else(|| anyhow!("Wallet not found for mint"))?;
-    let mint_quote = mint_wallet.mint_quote(initial_amount, None).await?;
+    let mint_quote = mint_wallet
+        .mint_quote(PaymentMethod::BOLT11, Some(initial_amount), None, None)
+        .await?;
 
     println!(
         "Pay this invoice to fund the wallet:\n{}",

+ 4 - 2
crates/cdk/examples/proof-selection.rs

@@ -5,7 +5,7 @@ use std::sync::Arc;
 use std::time::Duration;
 
 use cdk::nuts::nut00::ProofsMethods;
-use cdk::nuts::CurrencyUnit;
+use cdk::nuts::{CurrencyUnit, PaymentMethod};
 use cdk::wallet::Wallet;
 use cdk::Amount;
 use cdk_common::nut02::KeySetInfosMethods;
@@ -31,7 +31,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
     for amount in [64] {
         let amount = Amount::from(amount);
 
-        let quote = wallet.mint_quote(amount, None).await?;
+        let quote = wallet
+            .mint_quote(PaymentMethod::BOLT11, Some(amount), None, None)
+            .await?;
         let proofs = wallet
             .wait_and_mint_quote(
                 quote,

+ 78 - 0
crates/cdk/examples/receive-token.rs

@@ -0,0 +1,78 @@
+#![allow(missing_docs)]
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use cdk::nuts::nut00::ProofsMethods;
+use cdk::nuts::{CurrencyUnit, PaymentMethod};
+use cdk::wallet::{ReceiveOptions, SendOptions, Wallet};
+use cdk::Amount;
+use cdk_sqlite::wallet::memory;
+use rand::random;
+
+/// This example demonstrates how to receive a Cashu token.
+///
+/// It creates two wallets (sender and receiver), mints proofs in the sender wallet,
+/// creates a token, and then receives that token in the receiver wallet.
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    // Mint URL and currency unit
+    let mint_url = "https://fake.thesimplekid.dev";
+    let unit = CurrencyUnit::Sat;
+    let amount = Amount::from(10);
+
+    // Create sender wallet
+    let sender_seed = random::<[u8; 64]>();
+    let sender_store = Arc::new(memory::empty().await?);
+    let sender_wallet = Wallet::new(mint_url, unit.clone(), sender_store, sender_seed, None)?;
+
+    // Create receiver wallet (same mint, different seed/store)
+    let receiver_seed = random::<[u8; 64]>();
+    let receiver_store = Arc::new(memory::empty().await?);
+    let receiver_wallet = Wallet::new(mint_url, unit, receiver_store, receiver_seed, None)?;
+
+    // Step 1: Mint proofs in the sender wallet
+    println!("Creating mint quote for {} sats...", amount);
+    let quote = sender_wallet
+        .mint_quote(PaymentMethod::BOLT11, Some(amount), None, None)
+        .await?;
+    println!("Mint quote created. Invoice: {}", quote.request);
+
+    // Wait for the quote to be paid and mint the proofs
+    // Note: With the fake mint, this happens automatically
+    let proofs = sender_wallet
+        .wait_and_mint_quote(
+            quote,
+            Default::default(),
+            Default::default(),
+            Duration::from_secs(30),
+        )
+        .await?;
+
+    let minted_amount = proofs.total_amount()?;
+    println!("Minted {} sats in sender wallet", minted_amount);
+
+    // Step 2: Create a token to send
+    println!("\nPreparing to send {} sats...", amount);
+    let prepared_send = sender_wallet
+        .prepare_send(amount, SendOptions::default())
+        .await?;
+    let token = prepared_send.confirm(None).await?;
+    println!("Token created:\n{}", token);
+
+    // Step 3: Receive the token in the receiver wallet
+    println!("\nReceiving token in receiver wallet...");
+    let received_amount = receiver_wallet
+        .receive(&token.to_string(), ReceiveOptions::default())
+        .await?;
+    println!("Received {} sats in receiver wallet", received_amount);
+
+    // Verify balances
+    let sender_balance = sender_wallet.total_balance().await?;
+    let receiver_balance = receiver_wallet.total_balance().await?;
+    println!("\nFinal balances:");
+    println!("  Sender:   {} sats", sender_balance);
+    println!("  Receiver: {} sats", receiver_balance);
+
+    Ok(())
+}

+ 109 - 0
crates/cdk/examples/restore-wallet.rs

@@ -0,0 +1,109 @@
+#![allow(missing_docs)]
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use cdk::nuts::nut00::ProofsMethods;
+use cdk::nuts::{CurrencyUnit, PaymentMethod};
+use cdk::wallet::Wallet;
+use cdk::Amount;
+use cdk_sqlite::wallet::memory;
+use rand::random;
+
+/// This example demonstrates wallet restoration from a seed.
+///
+/// It shows:
+/// - Creating a wallet with a known seed
+/// - Minting proofs
+/// - Creating a new wallet with the same seed but fresh storage
+/// - Restoring proofs from the mint using the seed
+///
+/// This is useful for recovering a wallet on a new device or after data loss,
+/// as long as you have the original seed.
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    // Configuration
+    let mint_url = "https://fake.thesimplekid.dev";
+    let unit = CurrencyUnit::Sat;
+    let amount = Amount::from(50);
+
+    // Generate a seed - in production, use a mnemonic and store it securely!
+    // For this example, we use random bytes
+    let seed: [u8; 64] = random();
+    println!("Seed generated (first 32 bytes shown as hex)");
+    println!("(In production, use a BIP39 mnemonic instead)\n");
+
+    // ========================================
+    // Step 1: Create original wallet and mint proofs
+    // ========================================
+    println!("--- ORIGINAL WALLET ---");
+
+    let original_store = Arc::new(memory::empty().await?);
+    let original_wallet = Wallet::new(mint_url, unit.clone(), original_store, seed, None)?;
+
+    // Mint some proofs
+    println!("Minting {} sats...", amount);
+    let quote = original_wallet
+        .mint_quote(PaymentMethod::BOLT11, Some(amount), None, None)
+        .await?;
+
+    let proofs = original_wallet
+        .wait_and_mint_quote(
+            quote,
+            Default::default(),
+            Default::default(),
+            Duration::from_secs(30),
+        )
+        .await?;
+
+    let original_amount = proofs.total_amount()?;
+    let original_balance = original_wallet.total_balance().await?;
+    println!("Minted {} sats", original_amount);
+    println!("Original wallet balance: {} sats", original_balance);
+
+    // ========================================
+    // Step 2: Simulate wallet loss - create new wallet with same seed
+    // ========================================
+    println!("\n--- RESTORED WALLET ---");
+    println!("Simulating wallet recovery with same seed...\n");
+
+    // Create a fresh storage (simulating a new device or data loss)
+    let restored_store = Arc::new(memory::empty().await?);
+
+    // Create wallet with the SAME seed but EMPTY storage
+    let restored_wallet = Wallet::new(mint_url, unit, restored_store, seed, None)?;
+
+    // Check balance before restore - should be 0
+    let balance_before = restored_wallet.total_balance().await?;
+    println!("Balance before restore: {} sats", balance_before);
+
+    // ========================================
+    // Step 3: Restore proofs from the mint
+    // ========================================
+    println!("Restoring proofs from mint...");
+
+    // The restore method:
+    // 1. Generates the same blinded messages from the seed
+    // 2. Queries the mint for signatures on those messages
+    // 3. Reconstructs and stores unspent proofs
+    let restored_amount = restored_wallet.restore().await?;
+    println!("Restored {} sats from mint", restored_amount.unspent);
+    println!(
+        "Restored {} pending sats from mint",
+        restored_amount.pending
+    );
+    println!("Restored {} spent sats from mint", restored_amount.spent);
+
+    // Verify final balance
+    let final_balance = restored_wallet.total_balance().await?;
+    println!("\nFinal restored balance: {} sats", final_balance);
+    println!("Original balance was:   {} sats", original_balance);
+
+    if final_balance == original_balance {
+        println!("\nSuccess! Wallet fully restored.");
+    } else {
+        println!("\nNote: Balance may differ if some proofs were spent.");
+    }
+
+    Ok(())
+}

+ 203 - 0
crates/cdk/examples/revoke_send.rs

@@ -0,0 +1,203 @@
+#![allow(missing_docs)]
+
+use std::str::FromStr;
+use std::sync::Arc;
+use std::time::Duration;
+
+use bip39::Mnemonic;
+use cdk::amount::SplitTarget;
+use cdk::mint_url::MintUrl;
+use cdk::nuts::CurrencyUnit;
+use cdk::wallet::multi_mint_wallet::MultiMintWallet;
+use cdk::wallet::SendOptions;
+use cdk::Amount;
+use cdk_sqlite::wallet::memory;
+
+/// This example demonstrates the ability to revoke a send operation.
+///
+/// It shows:
+/// - Funding a wallet
+/// - Creating a send (generating a token)
+/// - Viewing pending sends
+/// - Checking send status
+/// - Revoking the send (reclaiming funds)
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    // Configuration
+    let mint_url = MintUrl::from_str("https://fake.thesimplekid.dev")?;
+    let unit = CurrencyUnit::Sat;
+
+    // Generate a seed
+    let mnemonic = Mnemonic::generate(12)?;
+    let seed = mnemonic.to_seed_normalized("");
+    println!("Generated mnemonic: {}", mnemonic);
+
+    // Create the MultiMintWallet
+    let localstore = Arc::new(memory::empty().await?);
+    let wallet = MultiMintWallet::new(localstore, seed, unit.clone()).await?;
+    println!("Created MultiMintWallet");
+
+    // Add a mint to the wallet
+    wallet.add_mint(mint_url.clone()).await?;
+    println!("Added mint: {}", mint_url);
+
+    // ========================================
+    // 1. FUND: Mint some tokens to start
+    // ========================================
+    let mint_amount = Amount::from(100);
+    println!("\n--- 1. FUNDING WALLET ---");
+    println!("Minting {} sats...", mint_amount);
+
+    let mint_quote = wallet.mint_quote(&mint_url, mint_amount, None).await?;
+
+    // Wait for quote to be paid (automatic with fake mint)
+    let _proofs = wallet
+        .wait_for_mint_quote(
+            &mint_url,
+            &mint_quote.id,
+            SplitTarget::default(),
+            None,
+            Duration::from_secs(60),
+        )
+        .await?;
+
+    let balance = wallet.total_balance().await?;
+    println!("Wallet funded. Balance: {} sats", balance);
+
+    // ========================================
+    // 2. SEND: Create a token
+    // ========================================
+    let send_amount = Amount::from(50);
+    println!("\n--- 2. CREATING SEND ---");
+    println!("Preparing to send {} sats...", send_amount);
+
+    // Prepare and confirm the send
+    let prepared_send = wallet
+        .prepare_send(mint_url.clone(), send_amount, SendOptions::default())
+        .await?;
+
+    let operation_id = prepared_send.operation_id();
+    let token = prepared_send.confirm(None).await?;
+
+    println!("Token created (Send Operation ID: {})", operation_id);
+    println!("Token: {}", token);
+
+    let balance_after_send = wallet.total_balance().await?;
+    println!("Balance after send: {} sats", balance_after_send);
+
+    // ========================================
+    // 3. INSPECT: Check pending status
+    // ========================================
+    println!("\n--- 3. INSPECTING STATUS ---");
+
+    // Get all pending sends
+    let pending_sends = wallet.get_pending_sends().await?;
+    println!("Pending sends count: {}", pending_sends.len());
+
+    for (mint, id) in &pending_sends {
+        println!("- Mint: {}, ID: {}", mint, id);
+    }
+
+    // Check specific status
+    let claimed = wallet
+        .check_send_status(mint_url.clone(), operation_id)
+        .await?;
+    println!("Is token claimed? {}", claimed);
+
+    if !claimed {
+        println!("Token is unclaimed. Revocation possible.");
+    } else {
+        println!("Token already claimed. Cannot revoke.");
+        return Ok(());
+    }
+
+    // ========================================
+    // 4. REVOKE: Reclaim the funds
+    // ========================================
+    println!("\n--- 4. REVOKING SEND ---");
+    println!("Revoking operation {}...", operation_id);
+
+    let reclaimed_amount = wallet.revoke_send(mint_url.clone(), operation_id).await?;
+    println!("Reclaimed {} sats", reclaimed_amount);
+
+    // ========================================
+    // 5. VERIFY: Check final state
+    // ========================================
+    println!("\n--- 5. VERIFYING STATE ---");
+
+    // Check pending sends again
+    let pending_after = wallet.get_pending_sends().await?;
+    println!("Pending sends after revocation: {}", pending_after.len());
+
+    // Check final balance
+    let final_balance = wallet.total_balance().await?;
+    println!("Final balance: {} sats", final_balance);
+
+    if final_balance > balance_after_send {
+        println!("SUCCESS: Funds restored!");
+    } else {
+        println!("WARNING: Balance did not increase.");
+    }
+
+    // Note on fees
+    if final_balance < mint_amount {
+        println!("(Note: Final balance may be slightly less than original due to mint fees)");
+    }
+
+    // ========================================
+    // 6. FINALIZE: Send and Claim (Happy Path)
+    // ========================================
+    println!("\n--- 6. SEND AND FINALIZE (Happy Path) ---");
+    let send_amount_2 = Amount::from(20);
+    println!("Sending {} sats to be claimed...", send_amount_2);
+
+    // Create a new send
+    let prepared_send_2 = wallet
+        .prepare_send(mint_url.clone(), send_amount_2, SendOptions::default())
+        .await?;
+    let operation_id_2 = prepared_send_2.operation_id();
+    let token_2 = prepared_send_2.confirm(None).await?;
+    println!("Token created: {}", token_2);
+
+    // Create a receiver wallet
+    println!("Creating receiver wallet...");
+    let receiver_seed = Mnemonic::generate(12)?.to_seed_normalized("");
+    let receiver_store = Arc::new(memory::empty().await?);
+    let receiver_wallet = MultiMintWallet::new(receiver_store, receiver_seed, unit).await?;
+    receiver_wallet.add_mint(mint_url.clone()).await?;
+
+    // Receiver claims the token
+    println!("Receiver claiming token...");
+    let received_amount = receiver_wallet
+        .receive(
+            &token_2.to_string(),
+            cdk::wallet::MultiMintReceiveOptions::default(),
+        )
+        .await?;
+    println!("Receiver got {} sats", received_amount);
+
+    // Check status from sender side
+    println!("Checking status from sender...");
+    let claimed_2 = wallet
+        .check_send_status(mint_url.clone(), operation_id_2)
+        .await?;
+    println!("Is token claimed? {}", claimed_2);
+
+    if claimed_2 {
+        println!("Token confirmed as claimed.");
+    } else {
+        println!("WARNING: Token should be claimed but status says false.");
+    }
+
+    // Verify pending sends is empty
+    let pending_final = wallet.get_pending_sends().await?;
+    println!("Pending sends count: {}", pending_final.len());
+
+    if pending_final.is_empty() {
+        println!("SUCCESS: Saga finalized and removed from pending.");
+    } else {
+        println!("WARNING: Pending sends not empty.");
+    }
+
+    Ok(())
+}

+ 6 - 2
crates/cdk/examples/wallet.rs

@@ -1,8 +1,10 @@
+#![allow(missing_docs)]
+
 use std::sync::Arc;
 use std::time::Duration;
 
 use cdk::nuts::nut00::ProofsMethods;
-use cdk::nuts::CurrencyUnit;
+use cdk::nuts::{CurrencyUnit, PaymentMethod};
 use cdk::wallet::{SendOptions, Wallet};
 use cdk::Amount;
 use cdk_sqlite::wallet::memory;
@@ -24,7 +26,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
     // Create a new wallet
     let wallet = Wallet::new(mint_url, unit, localstore, seed, None)?;
 
-    let quote = wallet.mint_quote(amount, None).await?;
+    let quote = wallet
+        .mint_quote(PaymentMethod::BOLT11, Some(amount), None, None)
+        .await?;
     let proofs = wallet
         .wait_and_mint_quote(
             quote,

+ 4 - 2
crates/cdk/src/mint/melt/melt_saga/tests.rs

@@ -2994,8 +2994,10 @@ async fn test_duplicate_lookup_id_prevents_second_pending() {
     if let Err(error) = setup_result2 {
         let error_msg = error.to_string().to_lowercase();
         assert!(
-            error_msg.contains("duplicate") || error_msg.contains("pending"),
-            "Error should mention duplicate or pending, got: {}",
+            error_msg.contains("duplicate")
+                || error_msg.contains("pending")
+                || error_msg.contains("already paid"),
+            "Error should mention duplicate, pending, or already paid, got: {}",
             error
         );
     }

+ 11 - 16
crates/cdk/src/mint/swap/swap_saga/mod.rs

@@ -5,7 +5,6 @@ use cdk_common::database::DynMintDatabase;
 use cdk_common::mint::{Operation, Saga, SwapSagaState};
 use cdk_common::nuts::BlindedMessage;
 use cdk_common::{database, Error, Proofs, ProofsMethods, PublicKey, QuoteId, State};
-use tokio::sync::Mutex;
 use tracing::instrument;
 
 use self::compensation::{CompensatingAction, RemoveSwapSetup};
@@ -96,7 +95,7 @@ pub struct SwapSaga<'a, S> {
     db: DynMintDatabase,
     pubsub: Arc<PubSubManager>,
     /// Compensating actions in LIFO order (most recent first)
-    compensations: Arc<Mutex<VecDeque<Box<dyn CompensatingAction>>>>,
+    compensations: VecDeque<Box<dyn CompensatingAction>>,
     /// Operation ID (used for saga tracking, generated upfront)
     operation_id: uuid::Uuid,
     state_data: S,
@@ -110,7 +109,7 @@ impl<'a> SwapSaga<'a, Initial> {
             mint,
             db,
             pubsub,
-            compensations: Arc::new(Mutex::new(VecDeque::new())),
+            compensations: VecDeque::new(),
             operation_id,
             state_data: Initial { operation_id },
         }
@@ -142,7 +141,7 @@ impl<'a> SwapSaga<'a, Initial> {
     /// - `DuplicateOutputs`: Output blinded messages already exist
     #[instrument(skip_all)]
     pub async fn setup_swap(
-        self,
+        mut self,
         input_proofs: &Proofs,
         blinded_messages: &[BlindedMessage],
         quote_id: Option<QuoteId>,
@@ -246,15 +245,11 @@ impl<'a> SwapSaga<'a, Initial> {
             self.pubsub.proof_state((*pk, State::Pending));
         }
         // Register compensation (uses LIFO via push_front)
-        let compensations = Arc::clone(&self.compensations);
-        compensations
-            .lock()
-            .await
-            .push_front(Box::new(RemoveSwapSetup {
-                blinded_secrets: blinded_secrets.clone(),
-                input_ys: ys.clone(),
-                operation_id: self.operation_id,
-            }));
+        self.compensations.push_front(Box::new(RemoveSwapSetup {
+            blinded_secrets: blinded_secrets.clone(),
+            input_ys: ys.clone(),
+            operation_id: self.operation_id,
+        }));
 
         // Transition to SetupComplete state
         Ok(SwapSaga {
@@ -360,7 +355,7 @@ impl SwapSaga<'_, Signed> {
     /// - `TokenAlreadySpent`: Input proofs were already spent by another operation
     /// - Propagates any database errors
     #[instrument(skip_all)]
-    pub async fn finalize(self) -> Result<cdk_common::nuts::SwapResponse, Error> {
+    pub async fn finalize(mut self) -> Result<cdk_common::nuts::SwapResponse, Error> {
         let blinded_secrets: Vec<PublicKey> = self
             .state_data
             .blinded_messages
@@ -451,7 +446,7 @@ impl SwapSaga<'_, Signed> {
             self.pubsub.proof_state((*pk, State::Spent));
         }
         // Clear compensations - swap is complete
-        self.compensations.lock().await.clear();
+        self.compensations.clear();
 
         Ok(cdk_common::nuts::SwapResponse::new(
             self.state_data.signatures,
@@ -466,7 +461,7 @@ impl<S> SwapSaga<'_, S> {
     /// after compensation has been triggered.
     #[instrument(skip_all)]
     async fn compensate_all(self) -> Result<(), Error> {
-        let mut compensations = self.compensations.lock().await;
+        let mut compensations = self.compensations;
 
         if compensations.is_empty() {
             return Ok(());

+ 3 - 3
crates/cdk/src/mint/swap/swap_saga/tests.rs

@@ -411,7 +411,7 @@ async fn test_swap_saga_compensation_clears_on_success() {
 
     let saga = SwapSaga::new(&mint, db, pubsub);
 
-    let compensations_before = saga.compensations.lock().await.len();
+    let compensations_before = saga.compensations.len();
 
     let saga = saga
         .setup_swap(
@@ -423,7 +423,7 @@ async fn test_swap_saga_compensation_clears_on_success() {
         .await
         .expect("Setup should succeed");
 
-    let compensations_after_setup = saga.compensations.lock().await.len();
+    let compensations_after_setup = saga.compensations.len();
     assert_eq!(
         compensations_after_setup, 1,
         "Should have one compensation after setup"
@@ -431,7 +431,7 @@ async fn test_swap_saga_compensation_clears_on_success() {
 
     let saga = saga.sign_outputs().await.expect("Signing should succeed");
 
-    let compensations_after_sign = saga.compensations.lock().await.len();
+    let compensations_after_sign = saga.compensations.len();
     assert_eq!(
         compensations_after_sign, 1,
         "Should still have one compensation after signing"

+ 1 - 1
crates/cdk/src/wallet/auth/auth_wallet.rs

@@ -3,6 +3,7 @@ use std::sync::Arc;
 
 use cdk_common::database::{self, WalletDatabase};
 use cdk_common::mint_url::MintUrl;
+use cdk_common::wallet::ProofInfo;
 use cdk_common::{AuthProof, Id, Keys, MintInfo};
 use serde::{Deserialize, Serialize};
 use tokio::sync::RwLock;
@@ -16,7 +17,6 @@ use crate::nuts::{
     nut12, AuthRequired, AuthToken, BlindAuthToken, CurrencyUnit, KeySetInfo, PreMintSecrets,
     Proofs, ProtectedEndpoint, State,
 };
-use crate::types::ProofInfo;
 use crate::wallet::mint_connector::AuthHttpClient;
 use crate::wallet::mint_metadata_cache::MintMetadataCache;
 use crate::{Amount, Error, OidcClient};

+ 0 - 1
crates/cdk/src/wallet/builder.rs

@@ -264,7 +264,6 @@ impl WalletBuilder {
             seed,
             client: client.clone(),
             subscription: SubscriptionManager::new(client, self.use_http_subscription),
-            in_error_swap_reverted_proofs: Arc::new(false.into()),
         })
     }
 }

+ 0 - 378
crates/cdk/src/wallet/issue/bolt11.rs

@@ -1,378 +0,0 @@
-use std::collections::HashMap;
-
-use cdk_common::nut00::KnownMethod;
-use cdk_common::nut04::MintMethodOptions;
-use cdk_common::wallet::{MintQuote, Transaction, TransactionDirection};
-use cdk_common::PaymentMethod;
-use tracing::instrument;
-
-use crate::amount::SplitTarget;
-use crate::dhke::construct_proofs;
-use crate::nuts::nut00::ProofsMethods;
-use crate::nuts::{
-    nut12, MintQuoteBolt11Request, MintQuoteBolt11Response, MintRequest, PreMintSecrets, Proofs,
-    SecretKey, SpendingConditions, State,
-};
-use crate::types::ProofInfo;
-use crate::util::unix_time;
-use crate::wallet::MintQuoteState;
-use crate::{Amount, Error, Wallet};
-
-impl Wallet {
-    /// Mint Quote
-    /// # Synopsis
-    /// ```rust,no_run
-    /// use std::sync::Arc;
-    ///
-    /// use cdk::amount::Amount;
-    /// use cdk::nuts::CurrencyUnit;
-    /// use cdk::wallet::Wallet;
-    /// use cdk_sqlite::wallet::memory;
-    /// use rand::random;
-    ///
-    /// #[tokio::main]
-    /// async fn main() -> anyhow::Result<()> {
-    ///     let seed = random::<[u8; 64]>();
-    ///     let mint_url = "https://fake.thesimplekid.dev";
-    ///     let unit = CurrencyUnit::Sat;
-    ///
-    ///     let localstore = memory::empty().await?;
-    ///     let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None)?;
-    ///     let amount = Amount::from(100);
-    ///
-    ///     let quote = wallet.mint_quote(amount, None).await?;
-    ///     Ok(())
-    /// }
-    /// ```
-    #[instrument(skip(self))]
-    pub async fn mint_quote(
-        &self,
-        amount: Amount,
-        description: Option<String>,
-    ) -> Result<MintQuote, Error> {
-        let mint_info = self.load_mint_info().await?;
-
-        let mint_url = self.mint_url.clone();
-        let unit = self.unit.clone();
-
-        // If we have a description, we check that the mint supports it.
-        if description.is_some() {
-            let settings = mint_info
-                .nuts
-                .nut04
-                .get_settings(
-                    &unit,
-                    &crate::nuts::PaymentMethod::Known(KnownMethod::Bolt11),
-                )
-                .ok_or(Error::UnsupportedUnit)?;
-
-            match settings.options {
-                Some(MintMethodOptions::Bolt11 { description }) if description => (),
-                _ => return Err(Error::InvoiceDescriptionUnsupported),
-            }
-        }
-
-        let secret_key = SecretKey::generate();
-
-        let request = MintQuoteBolt11Request {
-            amount,
-            unit: unit.clone(),
-            description,
-            pubkey: Some(secret_key.public_key()),
-        };
-
-        let quote_res = self.client.post_mint_quote(request).await?;
-
-        let quote = MintQuote::new(
-            quote_res.quote,
-            mint_url,
-            PaymentMethod::Known(KnownMethod::Bolt11),
-            Some(amount),
-            unit,
-            quote_res.request,
-            quote_res.expiry.unwrap_or(0),
-            Some(secret_key),
-        );
-
-        self.localstore.add_mint_quote(quote.clone()).await?;
-
-        Ok(quote)
-    }
-
-    /// Check mint quote status
-    #[instrument(skip(self, quote_id))]
-    pub async fn mint_quote_state(
-        &self,
-        quote_id: &str,
-    ) -> Result<MintQuoteBolt11Response<String>, Error> {
-        let response = self.client.get_mint_quote_status(quote_id).await?;
-
-        match self.localstore.get_mint_quote(quote_id).await? {
-            Some(quote) => {
-                let mut quote = quote;
-
-                quote.state = response.state;
-                self.localstore.add_mint_quote(quote).await?;
-            }
-            None => {
-                tracing::info!("Quote mint {} unknown", quote_id);
-            }
-        }
-
-        Ok(response)
-    }
-
-    /// Check status of pending mint quotes
-    #[instrument(skip(self))]
-    pub async fn check_all_mint_quotes(&self) -> Result<Amount, Error> {
-        let mint_quotes = self.localstore.get_unissued_mint_quotes().await?;
-        let mut total_amount = Amount::ZERO;
-
-        for mint_quote in mint_quotes {
-            match mint_quote.payment_method {
-                PaymentMethod::Known(KnownMethod::Bolt11) => {
-                    let mint_quote_response = self.mint_quote_state(&mint_quote.id).await?;
-
-                    if mint_quote_response.state == MintQuoteState::Paid {
-                        let proofs = self
-                            .mint(&mint_quote.id, SplitTarget::default(), None)
-                            .await?;
-                        total_amount += proofs.total_amount()?;
-                    }
-                }
-                PaymentMethod::Known(KnownMethod::Bolt12) => {
-                    let mint_quote_response = self.mint_bolt12_quote_state(&mint_quote.id).await?;
-                    if mint_quote_response.amount_paid > mint_quote_response.amount_issued {
-                        let proofs = self
-                            .mint_bolt12(&mint_quote.id, None, SplitTarget::default(), None)
-                            .await?;
-                        total_amount += proofs.total_amount()?;
-                    }
-                }
-                PaymentMethod::Custom(_) => {
-                    tracing::warn!("We cannot check unknown types");
-                }
-            }
-        }
-        Ok(total_amount)
-    }
-
-    /// Get active mint quotes
-    /// Returns mint quotes that are not expired and not yet issued.
-    #[instrument(skip(self))]
-    pub async fn get_active_mint_quotes(&self) -> Result<Vec<MintQuote>, Error> {
-        let mut mint_quotes = self.localstore.get_mint_quotes().await?;
-        let unix_time = unix_time();
-        mint_quotes.retain(|quote| {
-            quote.mint_url == self.mint_url
-                && quote.state != MintQuoteState::Issued
-                && quote.expiry > unix_time
-        });
-        Ok(mint_quotes)
-    }
-
-    /// Get unissued mint quotes
-    /// Returns bolt11 quotes where nothing has been issued yet (amount_issued = 0) and all bolt12 quotes.
-    /// Includes unpaid bolt11 quotes to allow checking with the mint if they've been paid (wallet state may be outdated).
-    /// Filters out quotes from other mints. Does not filter by expiry time to allow
-    /// checking with the mint if expired quotes can still be minted.
-    #[instrument(skip(self))]
-    pub async fn get_unissued_mint_quotes(&self) -> Result<Vec<MintQuote>, Error> {
-        let mut pending_quotes = self.localstore.get_unissued_mint_quotes().await?;
-        pending_quotes.retain(|quote| quote.mint_url == self.mint_url);
-        Ok(pending_quotes)
-    }
-
-    /// Mint
-    /// # Synopsis
-    /// ```rust,no_run
-    /// use std::sync::Arc;
-    ///
-    /// use anyhow::Result;
-    /// use cdk::amount::{Amount, SplitTarget};
-    /// use cdk::nuts::nut00::ProofsMethods;
-    /// use cdk::nuts::CurrencyUnit;
-    /// use cdk::wallet::Wallet;
-    /// use cdk_sqlite::wallet::memory;
-    /// use rand::random;
-    ///
-    /// #[tokio::main]
-    /// async fn main() -> Result<()> {
-    ///     let seed = random::<[u8; 64]>();
-    ///     let mint_url = "https://fake.thesimplekid.dev";
-    ///     let unit = CurrencyUnit::Sat;
-    ///
-    ///     let localstore = memory::empty().await?;
-    ///     let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None).unwrap();
-    ///     let amount = Amount::from(100);
-    ///
-    ///     let quote = wallet.mint_quote(amount, None).await?;
-    ///     let quote_id = quote.id;
-    ///     // To be called after quote request is paid
-    ///     let minted_proofs = wallet.mint(&quote_id, SplitTarget::default(), None).await?;
-    ///     let minted_amount = minted_proofs.total_amount()?;
-    ///
-    ///     Ok(())
-    /// }
-    /// ```
-    #[instrument(skip(self))]
-    pub async fn mint(
-        &self,
-        quote_id: &str,
-        amount_split_target: SplitTarget,
-        spending_conditions: Option<SpendingConditions>,
-    ) -> Result<Proofs, Error> {
-        let active_keyset_id = self.fetch_active_keyset().await?.id;
-        let fee_and_amounts = self
-            .get_keyset_fees_and_amounts_by_id(active_keyset_id)
-            .await?;
-
-        let quote_info = self
-            .localstore
-            .get_mint_quote(quote_id)
-            .await?
-            .ok_or(Error::UnknownQuote)?;
-
-        if quote_info.payment_method != PaymentMethod::Known(KnownMethod::Bolt11) {
-            return Err(Error::UnsupportedPaymentMethod);
-        }
-
-        let amount_mintable = quote_info.amount_mintable();
-
-        if amount_mintable == Amount::ZERO {
-            tracing::debug!("Amount mintable 0.");
-            return Err(Error::AmountUndefined);
-        }
-
-        let unix_time = unix_time();
-
-        if quote_info.expiry < unix_time && quote_info.expiry != 0 {
-            tracing::warn!("Attempting to mint with expired quote.");
-        }
-
-        let split_target = match amount_split_target {
-            SplitTarget::None => {
-                self.determine_split_target_values(amount_mintable, &fee_and_amounts)
-                    .await?
-            }
-            s => s,
-        };
-
-        let premint_secrets = match &spending_conditions {
-            Some(spending_conditions) => PreMintSecrets::with_conditions(
-                active_keyset_id,
-                amount_mintable,
-                &split_target,
-                spending_conditions,
-                &fee_and_amounts,
-            )?,
-            None => {
-                let amount_split =
-                    amount_mintable.split_targeted(&split_target, &fee_and_amounts)?;
-                let num_secrets = amount_split.len() as u32;
-
-                tracing::debug!(
-                    "Incrementing keyset {} counter by {}",
-                    active_keyset_id,
-                    num_secrets
-                );
-
-                // Atomically get the counter range we need
-                let new_counter = self
-                    .localstore
-                    .increment_keyset_counter(&active_keyset_id, num_secrets)
-                    .await?;
-
-                let count = new_counter - num_secrets;
-
-                PreMintSecrets::from_seed(
-                    active_keyset_id,
-                    count,
-                    &self.seed,
-                    amount_mintable,
-                    &split_target,
-                    &fee_and_amounts,
-                )?
-            }
-        };
-
-        let mut request = MintRequest {
-            quote: quote_id.to_string(),
-            outputs: premint_secrets.blinded_messages(),
-            signature: None,
-        };
-
-        if let Some(secret_key) = &quote_info.secret_key {
-            request.sign(secret_key.clone())?;
-        }
-
-        let mint_res = self
-            .client
-            .post_mint(&PaymentMethod::Known(KnownMethod::Bolt11), request)
-            .await?;
-
-        let keys = self.load_keyset_keys(active_keyset_id).await?;
-
-        // Verify the signature DLEQ is valid
-        {
-            for (sig, premint) in mint_res.signatures.iter().zip(&premint_secrets.secrets) {
-                let keys = self.load_keyset_keys(sig.keyset_id).await?;
-                let key = keys.amount_key(sig.amount).ok_or(Error::AmountKey)?;
-                match sig.verify_dleq(key, premint.blinded_message.blinded_secret) {
-                    Ok(_) | Err(nut12::Error::MissingDleqProof) => (),
-                    Err(_) => return Err(Error::CouldNotVerifyDleq),
-                }
-            }
-        }
-
-        let proofs = construct_proofs(
-            mint_res.signatures,
-            premint_secrets.rs(),
-            premint_secrets.secrets(),
-            &keys,
-        )?;
-
-        // Update quote with issued amount
-        let mut quote_info = quote_info;
-        quote_info.state = MintQuoteState::Issued;
-        quote_info.amount_issued = proofs.total_amount()?;
-
-        self.localstore.add_mint_quote(quote_info.clone()).await?;
-
-        let proof_infos = proofs
-            .iter()
-            .map(|proof| {
-                ProofInfo::new(
-                    proof.clone(),
-                    self.mint_url.clone(),
-                    State::Unspent,
-                    quote_info.unit.clone(),
-                )
-            })
-            .collect::<Result<Vec<ProofInfo>, _>>()?;
-
-        // Add new proofs to store
-        self.localstore.update_proofs(proof_infos, vec![]).await?;
-
-        // Add transaction to store
-        self.localstore
-            .add_transaction(Transaction {
-                mint_url: self.mint_url.clone(),
-                direction: TransactionDirection::Incoming,
-                amount: proofs.total_amount()?,
-                fee: Amount::ZERO,
-                unit: self.unit.clone(),
-                ys: proofs.ys()?,
-                timestamp: unix_time,
-                memo: None,
-                metadata: HashMap::new(),
-                quote_id: Some(quote_id.to_string()),
-                payment_request: Some(quote_info.request),
-                payment_proof: None,
-                payment_method: Some(quote_info.payment_method),
-            })
-            .await?;
-
-        Ok(proofs)
-    }
-}

+ 0 - 281
crates/cdk/src/wallet/issue/bolt12.rs

@@ -1,281 +0,0 @@
-use std::collections::HashMap;
-
-use cdk_common::nut00::KnownMethod;
-use cdk_common::nut04::MintMethodOptions;
-use cdk_common::nut25::MintQuoteBolt12Request;
-use cdk_common::wallet::{Transaction, TransactionDirection};
-use cdk_common::{Proofs, SecretKey};
-use tracing::instrument;
-
-use crate::amount::SplitTarget;
-use crate::dhke::construct_proofs;
-use crate::nuts::nut00::ProofsMethods;
-use crate::nuts::{
-    nut12, MintQuoteBolt12Response, MintRequest, PaymentMethod, PreMintSecrets, SpendingConditions,
-    State,
-};
-use crate::types::ProofInfo;
-use crate::util::unix_time;
-use crate::wallet::MintQuote;
-use crate::{Amount, Error, Wallet};
-
-impl Wallet {
-    /// Mint Bolt12
-    #[instrument(skip(self))]
-    pub async fn mint_bolt12_quote(
-        &self,
-        amount: Option<Amount>,
-        description: Option<String>,
-    ) -> Result<MintQuote, Error> {
-        let mint_info = self.load_mint_info().await?;
-
-        let mint_url = self.mint_url.clone();
-        let unit = &self.unit;
-
-        // If we have a description, we check that the mint supports it.
-        if description.is_some() {
-            let mint_method_settings = mint_info
-                .nuts
-                .nut04
-                .get_settings(
-                    unit,
-                    &crate::nuts::PaymentMethod::Known(KnownMethod::Bolt12),
-                )
-                .ok_or(Error::UnsupportedUnit)?;
-
-            match mint_method_settings.options {
-                Some(MintMethodOptions::Bolt11 { description }) if description => (),
-                _ => return Err(Error::InvoiceDescriptionUnsupported),
-            }
-        }
-
-        let secret_key = SecretKey::generate();
-
-        let mint_request = MintQuoteBolt12Request {
-            amount,
-            unit: self.unit.clone(),
-            description,
-            pubkey: secret_key.public_key(),
-        };
-
-        let quote_res = self.client.post_mint_bolt12_quote(mint_request).await?;
-
-        let quote = MintQuote::new(
-            quote_res.quote,
-            mint_url,
-            PaymentMethod::Known(KnownMethod::Bolt12),
-            amount,
-            unit.clone(),
-            quote_res.request,
-            quote_res.expiry.unwrap_or(0),
-            Some(secret_key),
-        );
-
-        self.localstore.add_mint_quote(quote.clone()).await?;
-
-        Ok(quote)
-    }
-
-    /// Mint bolt12
-    #[instrument(skip(self))]
-    pub async fn mint_bolt12(
-        &self,
-        quote_id: &str,
-        amount: Option<Amount>,
-        amount_split_target: SplitTarget,
-        spending_conditions: Option<SpendingConditions>,
-    ) -> Result<Proofs, Error> {
-        let active_keyset_id = self.fetch_active_keyset().await?.id;
-        let fee_and_amounts = self
-            .get_keyset_fees_and_amounts_by_id(active_keyset_id)
-            .await?;
-
-        let quote_info = self.localstore.get_mint_quote(quote_id).await?;
-
-        let quote_info = if let Some(quote) = quote_info {
-            if quote.expiry < unix_time() && quote.expiry != 0 {
-                tracing::info!("Attempting to mint expired quote.");
-            }
-
-            quote.clone()
-        } else {
-            return Err(Error::UnknownQuote);
-        };
-
-        let (quote_info, amount) = match amount {
-            Some(amount) => (quote_info, amount),
-            None => {
-                // If an amount it not supplied with check the status of the quote
-                // The mint will tell us how much can be minted
-                let state = self.mint_bolt12_quote_state(quote_id).await?;
-
-                let quote_info = self
-                    .localstore
-                    .get_mint_quote(quote_id)
-                    .await?
-                    .ok_or(Error::UnknownQuote)?;
-
-                (quote_info, state.amount_paid - state.amount_issued)
-            }
-        };
-
-        if amount == Amount::ZERO {
-            tracing::error!("Cannot mint zero amount.");
-            return Err(Error::UnpaidQuote);
-        }
-
-        let split_target = match amount_split_target {
-            SplitTarget::None => {
-                self.determine_split_target_values(amount, &fee_and_amounts)
-                    .await?
-            }
-            s => s,
-        };
-
-        let premint_secrets = match &spending_conditions {
-            Some(spending_conditions) => PreMintSecrets::with_conditions(
-                active_keyset_id,
-                amount,
-                &split_target,
-                spending_conditions,
-                &fee_and_amounts,
-            )?,
-            None => {
-                let amount_split = amount.split_targeted(&split_target, &fee_and_amounts)?;
-                let num_secrets = amount_split.len() as u32;
-
-                tracing::debug!(
-                    "Incrementing keyset {} counter by {}",
-                    active_keyset_id,
-                    num_secrets
-                );
-
-                // Atomically get the counter range we need
-                let new_counter = self
-                    .localstore
-                    .increment_keyset_counter(&active_keyset_id, num_secrets)
-                    .await?;
-
-                let count = new_counter - num_secrets;
-
-                PreMintSecrets::from_seed(
-                    active_keyset_id,
-                    count,
-                    &self.seed,
-                    amount,
-                    &split_target,
-                    &fee_and_amounts,
-                )?
-            }
-        };
-
-        let mut request = MintRequest {
-            quote: quote_id.to_string(),
-            outputs: premint_secrets.blinded_messages(),
-            signature: None,
-        };
-
-        if let Some(secret_key) = quote_info.secret_key.clone() {
-            request.sign(secret_key)?;
-        } else {
-            tracing::error!("Signature is required for bolt12.");
-            return Err(Error::SignatureMissingOrInvalid);
-        }
-
-        let mint_res = self
-            .client
-            .post_mint(&PaymentMethod::Known(KnownMethod::Bolt12), request)
-            .await?;
-
-        let keys = self.load_keyset_keys(active_keyset_id).await?;
-
-        // Verify the signature DLEQ is valid
-        {
-            for (sig, premint) in mint_res.signatures.iter().zip(&premint_secrets.secrets) {
-                let keys = self.load_keyset_keys(sig.keyset_id).await?;
-                let key = keys.amount_key(sig.amount).ok_or(Error::AmountKey)?;
-                match sig.verify_dleq(key, premint.blinded_message.blinded_secret) {
-                    Ok(_) | Err(nut12::Error::MissingDleqProof) => (),
-                    Err(_) => return Err(Error::CouldNotVerifyDleq),
-                }
-            }
-        }
-
-        let proofs = construct_proofs(
-            mint_res.signatures,
-            premint_secrets.rs(),
-            premint_secrets.secrets(),
-            &keys,
-        )?;
-
-        // Update quote with issued amount
-        let mut quote_info = self
-            .localstore
-            .get_mint_quote(quote_id)
-            .await?
-            .ok_or(Error::UnpaidQuote)?;
-        quote_info.amount_issued += proofs.total_amount()?;
-
-        self.localstore.add_mint_quote(quote_info.clone()).await?;
-
-        let proof_infos = proofs
-            .iter()
-            .map(|proof| {
-                ProofInfo::new(
-                    proof.clone(),
-                    self.mint_url.clone(),
-                    State::Unspent,
-                    quote_info.unit.clone(),
-                )
-            })
-            .collect::<Result<Vec<ProofInfo>, _>>()?;
-
-        // Add new proofs to store
-        self.localstore.update_proofs(proof_infos, vec![]).await?;
-
-        // Add transaction to store
-        self.localstore
-            .add_transaction(Transaction {
-                mint_url: self.mint_url.clone(),
-                direction: TransactionDirection::Incoming,
-                amount: proofs.total_amount()?,
-                fee: Amount::ZERO,
-                unit: self.unit.clone(),
-                ys: proofs.ys()?,
-                timestamp: unix_time(),
-                memo: None,
-                metadata: HashMap::new(),
-                quote_id: Some(quote_id.to_string()),
-                payment_request: Some(quote_info.request),
-                payment_proof: None,
-                payment_method: Some(quote_info.payment_method),
-            })
-            .await?;
-
-        Ok(proofs)
-    }
-
-    /// Check mint quote status
-    #[instrument(skip(self, quote_id))]
-    pub async fn mint_bolt12_quote_state(
-        &self,
-        quote_id: &str,
-    ) -> Result<MintQuoteBolt12Response<String>, Error> {
-        let response = self.client.get_mint_quote_bolt12_status(quote_id).await?;
-
-        match self.localstore.get_mint_quote(quote_id).await? {
-            Some(quote) => {
-                let mut quote = quote;
-                quote.amount_issued = response.amount_issued;
-                quote.amount_paid = response.amount_paid;
-
-                self.localstore.add_mint_quote(quote).await?;
-            }
-            None => {
-                tracing::info!("Quote mint {} unknown", quote_id);
-            }
-        }
-
-        Ok(response)
-    }
-}

+ 0 - 247
crates/cdk/src/wallet/issue/custom.rs

@@ -1,247 +0,0 @@
-use std::collections::HashMap;
-
-use cdk_common::nut04::MintMethodOptions;
-use cdk_common::wallet::{MintQuote, Transaction, TransactionDirection};
-use cdk_common::{MintQuoteState, Proofs, SecretKey};
-use tracing::instrument;
-
-use crate::amount::SplitTarget;
-use crate::dhke::construct_proofs;
-use crate::nuts::nut00::ProofsMethods;
-use crate::nuts::{
-    nut12, MintQuoteCustomRequest, MintRequest, PaymentMethod, PreMintSecrets, SpendingConditions,
-    State,
-};
-use crate::types::ProofInfo;
-use crate::util::unix_time;
-use crate::{Amount, Error, Wallet};
-
-impl Wallet {
-    /// Mint Quote for Custom Payment Method
-    #[instrument(skip(self))]
-    pub(super) async fn mint_quote_custom(
-        &self,
-        amount: Option<Amount>,
-        method: &PaymentMethod,
-        description: Option<String>,
-        extra: Option<String>,
-    ) -> Result<MintQuote, Error> {
-        let mint_url = self.mint_url.clone();
-        let unit = &self.unit;
-
-        self.refresh_keysets().await?;
-
-        // If we have a description, we check that the mint supports it.
-        if description.is_some() {
-            let payment_method = PaymentMethod::Custom(method.to_string());
-            let mint_method_settings = self
-                .localstore
-                .get_mint(mint_url.clone())
-                .await?
-                .ok_or(Error::IncorrectMint)?
-                .nuts
-                .nut04
-                .get_settings(unit, &payment_method)
-                .ok_or(Error::UnsupportedUnit)?;
-
-            match mint_method_settings.options {
-                Some(MintMethodOptions::Bolt11 { description }) if description => (),
-                _ => return Err(Error::InvoiceDescriptionUnsupported),
-            }
-        }
-
-        let secret_key = SecretKey::generate();
-
-        let amount = amount.ok_or(Error::AmountUndefined)?;
-
-        let mint_request = MintQuoteCustomRequest {
-            amount,
-            unit: self.unit.clone(),
-            description,
-            pubkey: Some(secret_key.public_key()),
-            extra: serde_json::from_str(&extra.unwrap_or_default())?,
-        };
-
-        let quote_res = self
-            .client
-            .post_mint_custom_quote(method, mint_request)
-            .await?;
-
-        let quote = MintQuote::new(
-            quote_res.quote,
-            mint_url,
-            PaymentMethod::Custom(method.to_string()),
-            Some(amount),
-            unit.clone(),
-            quote_res.request,
-            quote_res.expiry.unwrap_or(0),
-            Some(secret_key),
-        );
-        self.localstore.add_mint_quote(quote.clone()).await?;
-
-        Ok(quote)
-    }
-
-    /// Mint with custom payment method
-    /// This is used for all custom payment methods - delegates to existing mint logic
-    #[instrument(skip(self))]
-    pub(super) async fn mint_custom(
-        &self,
-        quote_id: &str,
-        amount_split_target: SplitTarget,
-        spending_conditions: Option<SpendingConditions>,
-    ) -> Result<Proofs, Error> {
-        self.refresh_keysets().await?;
-
-        let quote_info = self
-            .localstore
-            .get_mint_quote(quote_id)
-            .await?
-            .ok_or(Error::UnknownQuote)?;
-
-        // Verify it's a custom payment method
-        if !quote_info.payment_method.is_custom() {
-            return Err(Error::UnsupportedPaymentMethod);
-        }
-
-        let amount_mintable = quote_info.amount_mintable();
-
-        if amount_mintable == Amount::ZERO {
-            tracing::debug!("Amount mintable 0.");
-            return Err(Error::AmountUndefined);
-        }
-
-        let unix_time = unix_time();
-
-        if quote_info.expiry < unix_time && quote_info.expiry != 0 {
-            tracing::warn!("Attempting to mint with expired quote.");
-        }
-
-        let active_keyset_id = self.fetch_active_keyset().await?.id;
-        let fee_and_amounts = self
-            .get_keyset_fees_and_amounts_by_id(active_keyset_id)
-            .await?;
-
-        let premint_secrets = match &spending_conditions {
-            Some(spending_conditions) => PreMintSecrets::with_conditions(
-                active_keyset_id,
-                amount_mintable,
-                &amount_split_target,
-                spending_conditions,
-                &fee_and_amounts,
-            )?,
-            None => {
-                // Calculate how many secrets we'll need
-                let amount_split =
-                    amount_mintable.split_targeted(&amount_split_target, &fee_and_amounts)?;
-                let num_secrets = amount_split.len() as u32;
-
-                tracing::debug!(
-                    "Incrementing keyset {} counter by {}",
-                    active_keyset_id,
-                    num_secrets
-                );
-
-                // Atomically get the counter range we need
-                let new_counter = self
-                    .localstore
-                    .increment_keyset_counter(&active_keyset_id, num_secrets)
-                    .await?;
-
-                let count = new_counter - num_secrets;
-
-                PreMintSecrets::from_seed(
-                    active_keyset_id,
-                    count,
-                    &self.seed,
-                    amount_mintable,
-                    &amount_split_target,
-                    &fee_and_amounts,
-                )?
-            }
-        };
-
-        let mut request = MintRequest {
-            quote: quote_id.to_string(),
-            outputs: premint_secrets.blinded_messages(),
-            signature: None,
-        };
-
-        if let Some(secret_key) = &quote_info.secret_key {
-            request.sign(secret_key.clone())?;
-        }
-
-        if !quote_info.payment_method.is_custom() {
-            return Err(Error::UnsupportedPaymentMethod);
-        }
-
-        let mint_res = self
-            .client
-            .post_mint(&quote_info.payment_method, request)
-            .await?;
-
-        let keys = self.load_keyset_keys(active_keyset_id).await?;
-
-        // Verify the signature DLEQ is valid
-        {
-            for (sig, premint) in mint_res.signatures.iter().zip(&premint_secrets.secrets) {
-                let keys = self.load_keyset_keys(sig.keyset_id).await?;
-                let key = keys.amount_key(sig.amount).ok_or(Error::AmountKey)?;
-                match sig.verify_dleq(key, premint.blinded_message.blinded_secret) {
-                    Ok(_) | Err(nut12::Error::MissingDleqProof) => (),
-                    Err(_) => return Err(Error::CouldNotVerifyDleq),
-                }
-            }
-        }
-
-        let proofs = construct_proofs(
-            mint_res.signatures,
-            premint_secrets.rs(),
-            premint_secrets.secrets(),
-            &keys,
-        )?;
-
-        // Update quote with issued amount
-        let mut quote_info = quote_info;
-        quote_info.state = MintQuoteState::Issued;
-        quote_info.amount_issued = proofs.total_amount()?;
-
-        self.localstore.add_mint_quote(quote_info.clone()).await?;
-
-        let proof_infos = proofs
-            .iter()
-            .map(|proof| {
-                ProofInfo::new(
-                    proof.clone(),
-                    self.mint_url.clone(),
-                    State::Unspent,
-                    quote_info.unit.clone(),
-                )
-            })
-            .collect::<Result<Vec<ProofInfo>, _>>()?;
-
-        // Add new proofs to store
-        self.localstore.update_proofs(proof_infos, vec![]).await?;
-
-        // Add transaction to store
-        self.localstore
-            .add_transaction(Transaction {
-                mint_url: self.mint_url.clone(),
-                direction: TransactionDirection::Incoming,
-                amount: proofs.total_amount()?,
-                fee: Amount::ZERO,
-                unit: self.unit.clone(),
-                ys: proofs.ys()?,
-                timestamp: unix_time,
-                memo: None,
-                metadata: HashMap::new(),
-                quote_id: Some(quote_id.to_string()),
-                payment_request: Some(quote_info.request),
-                payment_proof: None,
-                payment_method: Some(quote_info.payment_method),
-            })
-            .await?;
-
-        Ok(proofs)
-    }
-}

+ 339 - 40
crates/cdk/src/wallet/issue/mod.rs

@@ -1,72 +1,371 @@
-mod bolt11;
-mod bolt12;
-mod custom;
+//! Issue (Mint) module for the wallet.
+//!
+//! This module provides functionality for minting new proofs via Bolt11, Bolt12, and Custom methods.
 
+pub(crate) mod saga;
+
+use cdk_common::nut00::KnownMethod;
+use cdk_common::nut04::MintMethodOptions;
+use cdk_common::nut25::MintQuoteBolt12Request;
 use cdk_common::PaymentMethod;
+pub(crate) use saga::MintSaga;
+use tracing::instrument;
 
 use crate::amount::SplitTarget;
-use crate::nuts::nut00::KnownMethod;
-use crate::nuts::{Proofs, SpendingConditions};
-use crate::wallet::MintQuote;
+use crate::nuts::{
+    MintQuoteBolt11Request, MintQuoteCustomRequest, Proofs, SecretKey, SpendingConditions,
+};
+use crate::util::unix_time;
+use crate::wallet::recovery::RecoveryAction;
+use crate::wallet::{MintQuote, MintQuoteState};
 use crate::{Amount, Error, Wallet};
 
 impl Wallet {
-    /// Unified mint quote method for all payment methods
-    /// Routes to the appropriate handler based on the payment method
-    pub async fn mint_quote_unified(
+    /// Mint Quote
+    #[instrument(skip(self, method))]
+    pub async fn mint_quote<T>(
         &self,
+        method: T,
         amount: Option<Amount>,
-        method: PaymentMethod,
         description: Option<String>,
         extra: Option<String>,
-    ) -> Result<MintQuote, Error> {
-        match method {
+    ) -> Result<MintQuote, Error>
+    where
+        T: Into<PaymentMethod>,
+    {
+        let mint_info = self.load_mint_info().await?;
+        let mint_url = self.mint_url.clone();
+        let unit = self.unit.clone();
+
+        let method: PaymentMethod = method.into();
+
+        // Check settings and description support
+        if description.is_some() {
+            let settings = mint_info
+                .nuts
+                .nut04
+                .get_settings(&unit, &method)
+                .ok_or(Error::UnsupportedUnit)?;
+
+            match settings.options {
+                Some(MintMethodOptions::Bolt11 { description }) if description => (),
+                _ => return Err(Error::InvoiceDescriptionUnsupported),
+            }
+        }
+
+        self.refresh_keysets().await?;
+
+        let secret_key = SecretKey::generate();
+
+        let (quote_id, request_str, expiry) = match &method {
             PaymentMethod::Known(KnownMethod::Bolt11) => {
-                // For bolt11, request should be empty or ignored, amount is required
                 let amount = amount.ok_or(Error::AmountUndefined)?;
-                self.mint_quote(amount, description).await
+                let request = MintQuoteBolt11Request {
+                    amount,
+                    unit: unit.clone(),
+                    description,
+                    pubkey: Some(secret_key.public_key()),
+                };
+
+                let response = self.client.post_mint_quote(request).await?;
+                (response.quote, response.request, response.expiry)
             }
             PaymentMethod::Known(KnownMethod::Bolt12) => {
-                // For bolt12, request is the offer string
-                self.mint_bolt12_quote(amount, description).await
+                let request = MintQuoteBolt12Request {
+                    amount,
+                    unit: unit.clone(),
+                    description,
+                    pubkey: secret_key.public_key(),
+                };
+
+                let response = self.client.post_mint_bolt12_quote(request).await?;
+                (response.quote, response.request, response.expiry)
             }
-            PaymentMethod::Custom(ref _custom_method) => {
-                self.mint_quote_custom(amount, &method, description, extra)
-                    .await
+            PaymentMethod::Custom(_) => {
+                let amount = amount.ok_or(Error::AmountUndefined)?;
+                let request = MintQuoteCustomRequest {
+                    amount,
+                    unit: unit.clone(),
+                    description,
+                    pubkey: Some(secret_key.public_key()),
+                    extra: serde_json::from_str(&extra.unwrap_or_default())?,
+                };
+
+                let response = self.client.post_mint_custom_quote(&method, request).await?;
+                (response.quote, response.request, response.expiry)
+            }
+        };
+
+        let quote = MintQuote::new(
+            quote_id,
+            mint_url,
+            method.clone(),
+            amount,
+            unit,
+            request_str,
+            expiry.unwrap_or(0),
+            Some(secret_key),
+        );
+
+        self.localstore.add_mint_quote(quote.clone()).await?;
+
+        Ok(quote)
+    }
+
+    /// Mint Bolt11 Quote (Legacy Wrapper)
+    pub async fn mint_bolt11_quote(
+        &self,
+        amount: Amount,
+        description: Option<String>,
+    ) -> Result<MintQuote, Error> {
+        self.mint_quote(PaymentMethod::BOLT11, Some(amount), description, None)
+            .await
+    }
+
+    /// Checks the state of a mint quote with the mint
+    async fn check_state(&self, mint_quote: &mut MintQuote) -> Result<(), Error> {
+        match mint_quote.payment_method {
+            PaymentMethod::Known(KnownMethod::Bolt11) => {
+                let mint_quote_response = self.client.get_mint_quote_status(&mint_quote.id).await?;
+                mint_quote.state = mint_quote_response.state;
+
+                match mint_quote_response.state {
+                    MintQuoteState::Paid => {
+                        mint_quote.amount_paid = mint_quote.amount.unwrap_or_default();
+                    }
+                    MintQuoteState::Issued => {
+                        mint_quote.amount_paid = mint_quote.amount.unwrap_or_default();
+                        mint_quote.amount_issued = mint_quote.amount.unwrap_or_default();
+                    }
+                    MintQuoteState::Unpaid => (),
+                }
+            }
+            PaymentMethod::Known(KnownMethod::Bolt12) => {
+                let mint_quote_response = self
+                    .client
+                    .get_mint_quote_bolt12_status(&mint_quote.id)
+                    .await?;
+
+                mint_quote.amount_issued = mint_quote_response.amount_issued;
+                mint_quote.amount_paid = mint_quote_response.amount_paid;
+            }
+            PaymentMethod::Custom(ref method) => {
+                let mint_quote_response = self
+                    .client
+                    .get_mint_quote_custom_status(method, &mint_quote.id)
+                    .await?;
+
+                mint_quote.state = mint_quote_response.state;
+
+                // Update amounts based on state
+                match mint_quote_response.state {
+                    MintQuoteState::Paid => {
+                        mint_quote.amount_paid = mint_quote_response.amount.unwrap_or_default();
+                    }
+                    MintQuoteState::Issued => {
+                        mint_quote.amount_paid = mint_quote_response.amount.unwrap_or_default();
+                        mint_quote.amount_issued = mint_quote_response.amount.unwrap_or_default();
+                    }
+                    MintQuoteState::Unpaid => (),
+                }
             }
         }
+
+        Ok(())
     }
 
-    /// Unified mint method for all payment methods
-    /// Routes to the appropriate handler based on the payment method stored in the quote
-    pub async fn mint_unified(
+    /// This method:
+    /// 1. Fetches the current quote state from the mint
+    /// 2. If there's an in-progress saga for this quote, attempts to complete it
+    /// 3. If the saga was compensated (rolled back), attempts a fresh mint
+    /// 4. Returns the updated quote
+    #[instrument(skip_all)]
+    async fn inner_check_mint_quote_status(
         &self,
-        quote_id: &str,
-        amount: Option<Amount>,
-        amount_split_target: SplitTarget,
-        spending_conditions: Option<SpendingConditions>,
-    ) -> Result<Proofs, Error> {
-        // Fetch the quote to determine the payment method
-        let quote_info = self
+        mut mint_quote: MintQuote,
+    ) -> Result<MintQuote, Error> {
+        let quote_id = mint_quote.id.clone();
+        // First, check/update the state from the mint
+        self.check_state(&mut mint_quote).await?;
+
+        // Check if there's an in-progress saga for this quote
+        if let Some(ref operation_id_str) = mint_quote.used_by_operation {
+            if let Ok(operation_id) = uuid::Uuid::parse_str(operation_id_str) {
+                match self.localstore.get_saga(&operation_id).await {
+                    Ok(Some(saga)) => {
+                        // Saga exists - try to complete it (like recovery does)
+                        tracing::info!(
+                            "Mint quote {} has in-progress saga {}, attempting to complete",
+                            quote_id,
+                            operation_id
+                        );
+
+                        let recovery_action = self.resume_issue_saga(&saga).await?;
+
+                        // If compensated, the saga was rolled back - attempt to mint again
+                        if recovery_action == RecoveryAction::Compensated {
+                            tracing::info!(
+                                "Saga {} was compensated, attempting fresh mint for quote {}",
+                                operation_id,
+                                quote_id
+                            );
+                        } else {
+                            // If the saga completed we need to get the updated state of the mint quote fn the db
+                            mint_quote = self
+                                .localstore
+                                .get_mint_quote(&quote_id)
+                                .await?
+                                .ok_or(Error::UnknownQuote)?;
+                        }
+                        // If Recovered or Skipped, just continue with the updated quote
+                    }
+                    Ok(None) => {
+                        // Orphaned reservation - release it
+                        tracing::warn!(
+                            "Mint quote {} has orphaned reservation for operation {}, releasing",
+                            quote_id,
+                            operation_id
+                        );
+                        if let Err(e) = self.localstore.release_mint_quote(&operation_id).await {
+                            tracing::warn!("Failed to release orphaned mint quote: {}", e);
+                        }
+                    }
+                    Err(e) => {
+                        tracing::warn!("Failed to check saga for mint quote {}: {}", quote_id, e);
+                        return Err(Error::Database(e));
+                    }
+                }
+            }
+        }
+
+        self.localstore.add_mint_quote(mint_quote.clone()).await?;
+        Ok(mint_quote)
+    }
+
+    /// Refresh the status of a single mint quote from the mint.
+    /// Updates local store with current state from mint.
+    /// Does NOT mint tokens - use mint() to mint a specific quote.
+    #[instrument(skip(self, quote_id))]
+    pub async fn refresh_mint_quote_status(&self, quote_id: &str) -> Result<MintQuote, Error> {
+        let mint_quote = self
             .localstore
             .get_mint_quote(quote_id)
             .await?
             .ok_or(Error::UnknownQuote)?;
 
-        match quote_info.payment_method {
-            PaymentMethod::Known(KnownMethod::Bolt11) => {
-                // Bolt11 doesn't need amount parameter
-                self.mint(quote_id, amount_split_target, spending_conditions)
-                    .await
-            }
-            PaymentMethod::Known(KnownMethod::Bolt12) => {
-                self.mint_bolt12(quote_id, amount, amount_split_target, spending_conditions)
-                    .await
+        let mint_quote = self.inner_check_mint_quote_status(mint_quote).await?;
+
+        Ok(mint_quote)
+    }
+
+    /// Refresh all unissued mint quote states from the mint.
+    /// Updates local store with current state from mint for each quote.
+    /// Does NOT mint tokens - use mint() or mint_unissued_quotes() for that.
+    #[instrument(skip(self))]
+    pub async fn refresh_all_mint_quotes(&self) -> Result<Vec<MintQuote>, Error> {
+        let mint_quotes = self.localstore.get_unissued_mint_quotes().await?;
+        let mut updated_quotes = Vec::new();
+
+        for mint_quote in mint_quotes {
+            match self.inner_check_mint_quote_status(mint_quote).await {
+                Ok(q) => updated_quotes.push(q),
+                Err(err) => {
+                    tracing::warn!("Could not check quote state: {}", err);
+                    continue;
+                }
             }
-            PaymentMethod::Custom(_) => {
-                self.mint_custom(quote_id, amount_split_target, spending_conditions)
+        }
+        Ok(updated_quotes)
+    }
+
+    /// Refresh states and mint all unissued quotes that have mintable amounts.
+    /// Returns the total amount minted across all quotes.
+    #[instrument(skip(self))]
+    pub async fn mint_unissued_quotes(&self) -> Result<Amount, Error> {
+        let mint_quotes = self.localstore.get_unissued_mint_quotes().await?;
+        let mut total_amount = Amount::ZERO;
+
+        for mint_quote in mint_quotes {
+            let current_amount_issued = mint_quote.amount_issued;
+
+            let mint_quote = match self.inner_check_mint_quote_status(mint_quote).await {
+                Ok(q) => q,
+                Err(err) => {
+                    tracing::warn!("Could not check quote state: {}", err);
+                    continue;
+                }
+            };
+
+            if mint_quote.amount_mintable() > Amount::ZERO {
+                if let Err(err) = self
+                    .mint(&mint_quote.id, SplitTarget::default(), None)
                     .await
+                {
+                    tracing::warn!("Could not mint quote {}: {}", mint_quote.id, err);
+                    continue;
+                }
             }
+
+            // Get updated quote to calculate minted amount
+            let updated_quote = match self.localstore.get_mint_quote(&mint_quote.id).await {
+                Ok(Some(q)) => q,
+                _ => continue,
+            };
+
+            total_amount = total_amount
+                .checked_add(
+                    updated_quote
+                        .amount_issued
+                        .checked_sub(current_amount_issued)
+                        .unwrap_or_default(),
+                )
+                .ok_or(Error::AmountOverflow)?;
         }
+        Ok(total_amount)
+    }
+
+    /// Get active mint quotes
+    /// Returns mint quotes that are not expired and not yet issued.
+    #[instrument(skip(self))]
+    pub async fn get_active_mint_quotes(&self) -> Result<Vec<MintQuote>, Error> {
+        let mut mint_quotes = self.localstore.get_mint_quotes().await?;
+        let unix_time = unix_time();
+        mint_quotes.retain(|quote| {
+            quote.mint_url == self.mint_url
+                && quote.state != MintQuoteState::Issued
+                && quote.expiry > unix_time
+        });
+        Ok(mint_quotes)
+    }
+
+    /// Get unissued mint quotes
+    /// Returns bolt11 quotes where nothing has been issued yet (amount_issued = 0) and all bolt12 quotes.
+    /// Includes unpaid bolt11 quotes to allow checking with the mint if they've been paid (wallet state may be outdated).
+    /// Filters out quotes from other mints. Does not filter by expiry time to allow
+    /// checking with the mint if expired quotes can still be minted.
+    #[instrument(skip(self))]
+    pub async fn get_unissued_mint_quotes(&self) -> Result<Vec<MintQuote>, Error> {
+        let mut pending_quotes = self.localstore.get_unissued_mint_quotes().await?;
+        pending_quotes.retain(|quote| quote.mint_url == self.mint_url);
+        Ok(pending_quotes)
+    }
+
+    /// Mint
+    #[instrument(skip(self))]
+    pub async fn mint(
+        &self,
+        quote_id: &str,
+        amount_split_target: SplitTarget,
+        spending_conditions: Option<SpendingConditions>,
+    ) -> Result<Proofs, Error> {
+        self.refresh_keysets().await?;
+
+        let saga = MintSaga::new(self);
+        let saga = saga
+            .prepare(quote_id, amount_split_target, spending_conditions)
+            .await?;
+        let saga = saga.execute().await?;
+
+        Ok(saga.into_proofs())
     }
 }

+ 221 - 0
crates/cdk/src/wallet/issue/saga/compensation.rs

@@ -0,0 +1,221 @@
+//! Compensation actions for the mint (issue) saga.
+//!
+//! When a saga step fails, compensating actions are executed in reverse order (LIFO)
+//! to undo all completed steps and restore the database to its pre-saga state.
+//!
+//! Note: For mint operations, the primary side effect before the API call is
+//! incrementing the keyset counter. Counter increments are not reversed because:
+//! 1. They don't cause data loss (just potentially unused counter values)
+//! 2. The secrets can be recovered via the restore process
+//! 3. Reversing could cause issues if concurrent operations used adjacent counters
+
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use cdk_common::database::{self, WalletDatabase};
+use tracing::instrument;
+use uuid::Uuid;
+
+use crate::wallet::saga::CompensatingAction;
+use crate::Error;
+
+/// Compensation action to release a mint quote reservation.
+/// Clears the used_by_operation field on the quote.
+pub struct ReleaseMintQuote {
+    /// Database reference
+    pub localstore: Arc<dyn WalletDatabase<database::Error> + Send + Sync>,
+    /// Operation ID that reserved the quote
+    pub operation_id: Uuid,
+}
+
+#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
+#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
+impl CompensatingAction for ReleaseMintQuote {
+    #[instrument(skip_all)]
+    async fn execute(&self) -> Result<(), Error> {
+        tracing::info!(
+            "Compensation: Releasing mint quote reserved by operation {}",
+            self.operation_id
+        );
+
+        self.localstore
+            .release_mint_quote(&self.operation_id)
+            .await
+            .map_err(Error::Database)?;
+
+        Ok(())
+    }
+
+    fn name(&self) -> &'static str {
+        "ReleaseMintQuote"
+    }
+}
+
+/// Compensation action for mint operations.
+/// Deletes the saga on failure. Counter increments are intentionally not reversed
+/// as they don't cause data loss and secrets can be recovered via restore.
+pub struct MintCompensation {
+    /// Database reference
+    pub localstore: Arc<dyn WalletDatabase<database::Error> + Send + Sync>,
+    /// Quote ID (for logging)
+    pub quote_id: String,
+    /// Saga ID for cleanup
+    pub saga_id: uuid::Uuid,
+}
+
+#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
+#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
+impl CompensatingAction for MintCompensation {
+    #[instrument(skip_all)]
+    async fn execute(&self) -> Result<(), Error> {
+        tracing::info!(
+            "Compensation: Mint operation for quote {} failed, no rollback needed",
+            self.quote_id
+        );
+
+        if let Err(e) = self.localstore.delete_saga(&self.saga_id).await {
+            tracing::warn!(
+                "Compensation: Failed to delete saga {}: {}. Will be cleaned up on recovery.",
+                self.saga_id,
+                e
+            );
+        }
+
+        Ok(())
+    }
+
+    fn name(&self) -> &'static str {
+        "MintCompensation"
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use cdk_common::nut00::KnownMethod;
+    use cdk_common::nuts::CurrencyUnit;
+    use cdk_common::wallet::{
+        MintQuote, OperationData, SwapOperationData, SwapSagaState, WalletSaga, WalletSagaState,
+    };
+    use cdk_common::{Amount, PaymentMethod};
+
+    use super::*;
+    use crate::wallet::saga::test_utils::*;
+    use crate::wallet::saga::CompensatingAction;
+
+    /// Create a test wallet saga for issue operations
+    fn test_issue_saga(mint_url: cdk_common::mint_url::MintUrl) -> WalletSaga {
+        WalletSaga::new(
+            uuid::Uuid::new_v4(),
+            WalletSagaState::Swap(SwapSagaState::ProofsReserved),
+            Amount::from(1000),
+            mint_url,
+            CurrencyUnit::Sat,
+            OperationData::Swap(SwapOperationData {
+                input_amount: Amount::from(1000),
+                output_amount: Amount::from(990),
+                counter_start: Some(0),
+                counter_end: Some(10),
+                blinded_messages: None,
+            }),
+        )
+    }
+
+    /// Create a test mint quote
+    fn test_mint_quote(mint_url: cdk_common::mint_url::MintUrl) -> MintQuote {
+        MintQuote::new(
+            format!("test_quote_{}", uuid::Uuid::new_v4()),
+            mint_url,
+            PaymentMethod::Known(KnownMethod::Bolt11),
+            Some(Amount::from(1000)),
+            CurrencyUnit::Sat,
+            "lnbc1000...".to_string(),
+            9999999999,
+            None,
+        )
+    }
+
+    // =========================================================================
+    // ReleaseMintQuote Tests
+    // =========================================================================
+
+    #[tokio::test]
+    async fn test_release_mint_quote_is_idempotent() {
+        let db = create_test_db().await;
+        let mint_url = test_mint_url();
+        let operation_id = uuid::Uuid::new_v4();
+
+        let mut quote = test_mint_quote(mint_url);
+        quote.used_by_operation = Some(operation_id.to_string());
+        db.add_mint_quote(quote.clone()).await.unwrap();
+
+        let compensation = ReleaseMintQuote {
+            localstore: db.clone(),
+            operation_id,
+        };
+
+        // Execute twice
+        compensation.execute().await.unwrap();
+        compensation.execute().await.unwrap();
+
+        let retrieved_quote = db.get_mint_quote(&quote.id).await.unwrap().unwrap();
+        assert!(retrieved_quote.used_by_operation.is_none());
+    }
+
+    #[tokio::test]
+    async fn test_release_mint_quote_handles_no_matching_quote() {
+        let db = create_test_db().await;
+        let operation_id = uuid::Uuid::new_v4();
+
+        // Don't add any quote - compensation should still succeed
+        let compensation = ReleaseMintQuote {
+            localstore: db.clone(),
+            operation_id,
+        };
+
+        // Should not error even with no matching quote
+        let result = compensation.execute().await;
+        assert!(result.is_ok());
+    }
+
+    // =========================================================================
+    // MintCompensation Tests
+    // =========================================================================
+
+    #[tokio::test]
+    async fn test_mint_compensation_is_idempotent() {
+        let db = create_test_db().await;
+        let mint_url = test_mint_url();
+
+        let saga = test_issue_saga(mint_url);
+        let saga_id = saga.id;
+        db.add_saga(saga).await.unwrap();
+
+        let compensation = MintCompensation {
+            localstore: db.clone(),
+            quote_id: "test_quote".to_string(),
+            saga_id,
+        };
+
+        // Execute twice - should succeed both times
+        compensation.execute().await.unwrap();
+        compensation.execute().await.unwrap();
+
+        assert!(db.get_saga(&saga_id).await.unwrap().is_none());
+    }
+
+    #[tokio::test]
+    async fn test_mint_compensation_handles_missing_saga() {
+        let db = create_test_db().await;
+        let saga_id = uuid::Uuid::new_v4();
+
+        let compensation = MintCompensation {
+            localstore: db.clone(),
+            quote_id: "test_quote".to_string(),
+            saga_id,
+        };
+
+        // Should succeed even without saga
+        let result = compensation.execute().await;
+        assert!(result.is_ok());
+    }
+}

+ 493 - 0
crates/cdk/src/wallet/issue/saga/mod.rs

@@ -0,0 +1,493 @@
+//! Mint (Issue) Saga - Type State Pattern Implementation
+//!
+//! This module implements the saga pattern for mint operations using the typestate
+//! pattern to enforce valid state transitions at compile-time.
+//!
+//! # State Flow
+//!
+//! ```text
+//! [saga created] ──► SecretsPrepared ──► MintRequested ──► [completed]
+//!                         │                    │
+//!                         │                    ├─ replay succeeds ────► [completed]
+//!                         │                    ├─ restore succeeds ───► [completed]
+//!                         │                    └─ restore fails ──────► [compensated] (proofs may be lost*)
+//!                         │
+//!                         └─ recovery ────────────────────────────────► [compensated]
+//! ```
+//!
+//! *Note: If restore fails after MintRequested, proofs may have been issued but not recovered.
+//! Run `wallet.restore()` to attempt full recovery.
+//!
+//! # States
+//!
+//! | State | Description |
+//! |-------|-------------|
+//! | `SecretsPrepared` | Pre-mint secrets created and counter incremented, ready to request signatures |
+//! | `MintRequested` | Mint request sent to mint, awaiting signatures for new proofs |
+//!
+//! # Recovery Outcomes
+//!
+//! | Outcome | Description |
+//! |---------|-------------|
+//! | `[completed]` | Minting succeeded, new proofs saved to wallet |
+//! | `[compensated]` | Minting failed or rolled back, quote released |
+
+use std::collections::HashMap;
+
+use cdk_common::nut00::KnownMethod;
+use cdk_common::wallet::{
+    IssueSagaState, MintOperationData, OperationData, ProofInfo, Transaction, TransactionDirection,
+    WalletSaga, WalletSagaState,
+};
+use cdk_common::PaymentMethod;
+use tracing::instrument;
+
+use self::compensation::{MintCompensation, ReleaseMintQuote};
+use self::state::{Finalized, Initial, Prepared};
+use crate::amount::SplitTarget;
+use crate::dhke::construct_proofs;
+use crate::nuts::nut00::ProofsMethods;
+use crate::nuts::{nut12, MintRequest, PreMintSecrets, Proofs, SpendingConditions, State};
+use crate::util::unix_time;
+use crate::wallet::saga::{
+    add_compensation, clear_compensations, execute_compensations, new_compensations, Compensations,
+};
+use crate::{Amount, Error, Wallet};
+
+pub(crate) mod compensation;
+pub(crate) mod resume;
+pub(crate) mod state;
+
+/// Saga pattern implementation for mint (issue) operations.
+///
+/// Uses the typestate pattern to enforce valid state transitions at compile-time.
+/// Each state (Initial, Prepared, Finalized) is a distinct type, and operations
+/// are only available on the appropriate type.
+pub(crate) struct MintSaga<'a, S> {
+    /// Wallet reference
+    wallet: &'a Wallet,
+    /// Compensating actions in LIFO order (most recent first)
+    compensations: Compensations,
+    /// State-specific data
+    state_data: S,
+}
+
+impl<'a> MintSaga<'a, Initial> {
+    /// Create a new mint saga in the Initial state.
+    pub fn new(wallet: &'a Wallet) -> Self {
+        let operation_id = uuid::Uuid::new_v4();
+
+        Self {
+            wallet,
+            compensations: new_compensations(),
+            state_data: Initial { operation_id },
+        }
+    }
+
+    /// Prepare common logic for all mint types
+    #[allow(clippy::too_many_arguments)]
+    async fn prepare_common(
+        mut self,
+        quote_id: &str,
+        quote_info: cdk_common::wallet::MintQuote,
+        amount: Amount,
+        amount_split_target: SplitTarget,
+        spending_conditions: Option<SpendingConditions>,
+        fee_and_amounts: cdk_common::amount::FeeAndAmounts,
+        active_keyset_id: cdk_common::nut02::Id,
+    ) -> Result<MintSaga<'a, Prepared>, Error> {
+        // Reserve the quote to prevent concurrent operations from using it
+        self.wallet
+            .localstore
+            .reserve_mint_quote(quote_id, &self.state_data.operation_id)
+            .await?;
+
+        // Register compensation to release quote on failure
+        add_compensation(
+            &mut self.compensations,
+            Box::new(ReleaseMintQuote {
+                localstore: self.wallet.localstore.clone(),
+                operation_id: self.state_data.operation_id,
+            }),
+        )
+        .await;
+
+        if amount == Amount::ZERO {
+            tracing::debug!("Amount mintable 0.");
+            return Err(Error::AmountUndefined);
+        }
+
+        let unix_time = unix_time();
+        if quote_info.expiry < unix_time && quote_info.expiry != 0 {
+            tracing::warn!("Attempting to mint with expired quote.");
+        }
+
+        let split_target = match amount_split_target {
+            SplitTarget::None => {
+                self.wallet
+                    .determine_split_target_values(amount, &fee_and_amounts)
+                    .await?
+            }
+            s => s,
+        };
+
+        let premint_secrets = match &spending_conditions {
+            Some(spending_conditions) => PreMintSecrets::with_conditions(
+                active_keyset_id,
+                amount,
+                &split_target,
+                spending_conditions,
+                &fee_and_amounts,
+            )?,
+            None => {
+                let amount_split = amount.split_targeted(&split_target, &fee_and_amounts)?;
+                let num_secrets = amount_split.len() as u32;
+
+                tracing::debug!(
+                    "Incrementing keyset {} counter by {}",
+                    active_keyset_id,
+                    num_secrets
+                );
+
+                let new_counter = self
+                    .wallet
+                    .localstore
+                    .increment_keyset_counter(&active_keyset_id, num_secrets)
+                    .await?;
+
+                let count = new_counter - num_secrets;
+
+                PreMintSecrets::from_seed(
+                    active_keyset_id,
+                    count,
+                    &self.wallet.seed,
+                    amount,
+                    &split_target,
+                    &fee_and_amounts,
+                )?
+            }
+        };
+
+        let mut request = MintRequest {
+            quote: quote_id.to_string(),
+            outputs: premint_secrets.blinded_messages(),
+            signature: None,
+        };
+
+        if let Some(secret_key) = &quote_info.secret_key {
+            request.sign(secret_key.clone())?;
+        } else if quote_info.payment_method.is_bolt12() {
+            // Bolt12 requires signature
+            tracing::error!("Signature is required for bolt12.");
+            return Err(Error::SignatureMissingOrInvalid);
+        }
+
+        let operation_id = self.state_data.operation_id;
+
+        // Get counter range for recovery
+        let counter_end = self
+            .wallet
+            .localstore
+            .increment_keyset_counter(&active_keyset_id, 0)
+            .await?;
+        let counter_start = counter_end.saturating_sub(premint_secrets.secrets.len() as u32);
+
+        // Persist saga state for crash recovery
+        let saga = WalletSaga::new(
+            operation_id,
+            WalletSagaState::Issue(IssueSagaState::SecretsPrepared),
+            amount,
+            self.wallet.mint_url.clone(),
+            self.wallet.unit.clone(),
+            OperationData::Mint(MintOperationData {
+                quote_id: quote_id.to_string(),
+                amount,
+                counter_start: Some(counter_start),
+                counter_end: Some(counter_end),
+                blinded_messages: Some(request.outputs.clone()),
+            }),
+        );
+
+        self.wallet.localstore.add_saga(saga.clone()).await?;
+
+        // Register compensation (deletes saga on failure)
+        add_compensation(
+            &mut self.compensations,
+            Box::new(MintCompensation {
+                localstore: self.wallet.localstore.clone(),
+                quote_id: quote_id.to_string(),
+                saga_id: operation_id,
+            }),
+        )
+        .await;
+
+        // Transition to Prepared state
+        Ok(MintSaga {
+            wallet: self.wallet,
+            compensations: self.compensations,
+            state_data: Prepared {
+                operation_id: self.state_data.operation_id,
+                quote_id: quote_id.to_string(),
+                quote_info: quote_info.clone(),
+                active_keyset_id,
+                premint_secrets,
+                mint_request: request,
+                payment_method: quote_info.payment_method.clone(),
+                saga,
+            },
+        })
+    }
+
+    /// Prepare the mint operation.
+    ///
+    /// This is the first step in the saga. It:
+    /// 1. Validates the quote
+    /// 2. Creates premint secrets (increments counter if needed)
+    /// 3. Prepares the mint request
+    #[instrument(skip_all)]
+    pub async fn prepare(
+        self,
+        quote_id: &str,
+        amount_split_target: SplitTarget,
+        spending_conditions: Option<SpendingConditions>,
+    ) -> Result<MintSaga<'a, Prepared>, Error> {
+        let mut quote_info = self
+            .wallet
+            .localstore
+            .get_mint_quote(quote_id)
+            .await?
+            .ok_or(Error::UnknownQuote)?;
+
+        tracing::info!(
+            "Preparing mint for quote {} with operation {} method {}",
+            quote_id,
+            self.state_data.operation_id,
+            quote_info.payment_method
+        );
+
+        let mut amount = quote_info.amount_mintable();
+
+        if amount == Amount::ZERO {
+            self.wallet
+                .inner_check_mint_quote_status(quote_info.clone())
+                .await?;
+
+            quote_info = self
+                .wallet
+                .localstore
+                .get_mint_quote(quote_id)
+                .await?
+                .ok_or(Error::UnknownQuote)?;
+
+            amount = quote_info.amount_mintable();
+        }
+
+        let active_keyset_id = self.wallet.fetch_active_keyset().await?.id;
+        let fee_and_amounts = self
+            .wallet
+            .get_keyset_fees_and_amounts_by_id(active_keyset_id)
+            .await?;
+
+        self.prepare_common(
+            quote_id,
+            quote_info,
+            amount,
+            amount_split_target,
+            spending_conditions,
+            fee_and_amounts,
+            active_keyset_id,
+        )
+        .await
+    }
+}
+
+impl<'a> MintSaga<'a, Prepared> {
+    /// Execute the mint operation.
+    ///
+    /// Posts mint request, verifies DLEQ proofs, constructs and stores proofs,
+    /// updates quote state, and records transaction. On success, compensations
+    /// are cleared.
+    #[instrument(skip_all)]
+    pub async fn execute(self) -> Result<MintSaga<'a, Finalized>, Error> {
+        let MintSaga {
+            wallet,
+            mut compensations,
+            state_data,
+        } = self;
+
+        let Prepared {
+            operation_id,
+            quote_id,
+            quote_info,
+            active_keyset_id,
+            premint_secrets,
+            mint_request,
+            payment_method,
+            saga,
+        } = state_data;
+
+        tracing::info!(
+            "Executing mint for quote {} with operation {}",
+            quote_id,
+            operation_id
+        );
+
+        let logic_res = async {
+            // Get counter range for recovery
+            let counter_end = wallet
+                .localstore
+                .increment_keyset_counter(&active_keyset_id, 0)
+                .await?;
+            let counter_start =
+                counter_end.saturating_sub(premint_secrets.secrets.len() as u32);
+
+            // Update saga state to MintRequested BEFORE making the mint call
+            // This is write-ahead logging - if we crash after this, recovery knows
+            // the mint request may have been sent
+            let mut updated_saga = saga.clone();
+            updated_saga.update_state(WalletSagaState::Issue(IssueSagaState::MintRequested));
+            if let OperationData::Mint(ref mut data) = updated_saga.data {
+                data.counter_start = Some(counter_start);
+                data.counter_end = Some(counter_end);
+                data.blinded_messages = Some(mint_request.outputs.clone());
+            }
+
+            if !wallet.localstore.update_saga(updated_saga).await? {
+                return Err(Error::ConcurrentUpdate);
+            }
+
+            let mint_res = wallet
+                .client
+                .post_mint(&payment_method, mint_request.clone())
+                .await?;
+
+            let keys = wallet.load_keyset_keys(active_keyset_id).await?;
+
+            for (sig, premint) in mint_res.signatures.iter().zip(&premint_secrets.secrets) {
+                let keys = wallet.load_keyset_keys(sig.keyset_id).await?;
+                let key = keys.amount_key(sig.amount).ok_or(Error::AmountKey)?;
+                match sig.verify_dleq(key, premint.blinded_message.blinded_secret) {
+                    Ok(_) | Err(nut12::Error::MissingDleqProof) => (),
+                    Err(_) => return Err(Error::CouldNotVerifyDleq),
+                }
+            }
+
+            let proofs = construct_proofs(
+                mint_res.signatures,
+                premint_secrets.rs(),
+                premint_secrets.secrets(),
+                &keys,
+            )?;
+
+            let minted_amount = proofs.total_amount()?;
+
+            let mut quote_info = quote_info;
+
+            if payment_method == PaymentMethod::Known(KnownMethod::Bolt11) {
+                quote_info.state = cdk_common::MintQuoteState::Issued;
+            }
+
+            quote_info.amount_issued += minted_amount;
+            wallet.localstore.add_mint_quote(quote_info.clone()).await?;
+
+            let proof_infos = proofs
+                .iter()
+                .map(|proof| {
+                    ProofInfo::new(
+                        proof.clone(),
+                        wallet.mint_url.clone(),
+                        State::Unspent,
+                        quote_info.unit.clone(),
+                    )
+                })
+                .collect::<Result<Vec<ProofInfo>, _>>()?;
+
+            wallet.localstore.update_proofs(proof_infos, vec![]).await?;
+
+            wallet
+                .localstore
+                .add_transaction(Transaction {
+                    mint_url: wallet.mint_url.clone(),
+                    direction: TransactionDirection::Incoming,
+                    amount: minted_amount,
+                    fee: Amount::ZERO,
+                    unit: wallet.unit.clone(),
+                    ys: proofs.ys()?,
+                    timestamp: unix_time(),
+                    memo: None,
+                    metadata: HashMap::new(),
+                    quote_id: Some(quote_id.clone()),
+                    payment_request: Some(quote_info.request.clone()),
+                    payment_proof: None,
+                    payment_method: Some(payment_method.clone()),
+                    saga_id: Some(operation_id),
+                })
+                .await?;
+
+            // Release the mint quote reservation - operation completed successfully
+            // This is important for Bolt12 partial minting where the same quote
+            // may be used for multiple mint operations.
+            if let Err(e) = wallet.localstore.release_mint_quote(&operation_id).await {
+                tracing::warn!(
+                    "Failed to release mint quote for operation {}: {}. Quote may remain marked as reserved.",
+                    operation_id,
+                    e
+                );
+                // Don't fail the mint - proofs are already stored
+            }
+
+            Ok(Finalized { proofs })
+        }
+        .await;
+
+        match logic_res {
+            Ok(finalized_data) => {
+                clear_compensations(&mut compensations).await;
+
+                if let Err(e) = wallet.localstore.delete_saga(&operation_id).await {
+                    tracing::warn!(
+                        "Failed to delete mint saga {}: {}. Will be cleaned up on recovery.",
+                        operation_id,
+                        e
+                    );
+                    // Don't fail the mint if saga deletion fails - orphaned saga is harmless
+                }
+
+                Ok(MintSaga {
+                    wallet,
+                    compensations,
+                    state_data: finalized_data,
+                })
+            }
+            Err(e) => {
+                if e.is_definitive_failure() {
+                    tracing::warn!(
+                        "Mint saga execution failed (definitive): {}. Running compensations.",
+                        e
+                    );
+                    if let Err(comp_err) = execute_compensations(&mut compensations).await {
+                        tracing::error!("Compensation failed: {}", comp_err);
+                    }
+                } else {
+                    tracing::warn!("Mint saga execution failed (ambiguous): {}.", e,);
+                }
+                Err(e)
+            }
+        }
+    }
+}
+
+impl<'a> MintSaga<'a, Finalized> {
+    /// Consume the saga and return the minted proofs
+    pub fn into_proofs(self) -> Proofs {
+        self.state_data.proofs
+    }
+}
+
+impl<S: std::fmt::Debug> std::fmt::Debug for MintSaga<'_, S> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("MintSaga")
+            .field("state_data", &self.state_data)
+            .finish_non_exhaustive()
+    }
+}

+ 543 - 0
crates/cdk/src/wallet/issue/saga/resume.rs

@@ -0,0 +1,543 @@
+//! Resume logic for issue (mint) sagas after crash recovery.
+//!
+//! This module handles resuming incomplete issue sagas that were interrupted
+//! by a crash. It attempts to recover outputs using stored blinded messages.
+//!
+//! # Recovery Strategy
+//!
+//! For `MintRequested` state, we use a replay-first strategy:
+//! 1. **Replay**: Attempt to replay the original `post_mint` request.
+//!    If the mint cached the response (NUT-19), we get signatures immediately.
+//! 2. **Fallback**: If replay fails, use `/restore` to recover outputs.
+
+use std::collections::HashMap;
+
+use cdk_common::wallet::{
+    IssueSagaState, MintOperationData, OperationData, ProofInfo, Transaction, TransactionDirection,
+    WalletSaga,
+};
+use tracing::instrument;
+
+use crate::dhke::construct_proofs;
+use crate::nuts::{MintRequest, State};
+use crate::util::unix_time;
+use crate::wallet::issue::saga::compensation::ReleaseMintQuote;
+use crate::wallet::recovery::{RecoveryAction, RecoveryHelpers};
+use crate::wallet::saga::CompensatingAction;
+use crate::{Amount, Error, Wallet};
+
+impl Wallet {
+    /// Resume an incomplete issue saga after crash recovery.
+    ///
+    /// Recovery depends on state:
+    /// - SecretsPrepared: No mint request sent, safe to compensate.
+    /// - MintRequested: Mint request sent, attempt to recover outputs.
+    #[instrument(skip(self, saga))]
+    pub async fn resume_issue_saga(&self, saga: &WalletSaga) -> Result<RecoveryAction, Error> {
+        let state = match &saga.state {
+            cdk_common::wallet::WalletSagaState::Issue(s) => s,
+            _ => {
+                return Err(Error::Custom(format!(
+                    "Invalid saga state type for issue saga {}",
+                    saga.id
+                )))
+            }
+        };
+
+        let data = match &saga.data {
+            OperationData::Mint(d) => d,
+            _ => {
+                return Err(Error::Custom(format!(
+                    "Invalid operation data type for issue saga {}",
+                    saga.id
+                )))
+            }
+        };
+
+        match state {
+            IssueSagaState::SecretsPrepared => {
+                // No mint request was sent - safe to delete saga
+                // Counter increments are not reversed (by design)
+                tracing::info!(
+                    "Issue saga {} in SecretsPrepared state - cleaning up",
+                    saga.id
+                );
+                self.compensate_issue(&saga.id).await?;
+                Ok(RecoveryAction::Compensated)
+            }
+            IssueSagaState::MintRequested => {
+                // Mint request was sent - try to recover outputs
+                tracing::info!(
+                    "Issue saga {} in MintRequested state - attempting recovery",
+                    saga.id
+                );
+                // Return the result directly (RecoveryAction)
+                self.complete_issue_from_restore(&saga.id, data).await
+            }
+        }
+    }
+
+    /// Complete an issue by first trying replay, then falling back to restore.
+    /// Replay leverages NUT-19 caching.
+    async fn complete_issue_from_restore(
+        &self,
+        saga_id: &uuid::Uuid,
+        data: &MintOperationData,
+    ) -> Result<RecoveryAction, Error> {
+        // Try replay first
+        if let Some(proofs) = self.try_replay_mint(saga_id, data).await? {
+            // Replay succeeded - save proofs and clean up
+            self.localstore
+                .update_proofs(proofs.clone(), vec![])
+                .await?;
+
+            // Record transaction (best-effort, don't fail recovery if this fails)
+            if let Err(e) = self
+                .record_recovered_issue_transaction(saga_id, &data.quote_id, &proofs)
+                .await
+            {
+                tracing::warn!(
+                    "Failed to record transaction for recovered issue saga {}: {}",
+                    saga_id,
+                    e
+                );
+            }
+
+            self.localstore.delete_saga(saga_id).await?;
+            return Ok(RecoveryAction::Recovered);
+        }
+
+        // Replay failed, fall back to /restore
+        let new_proofs = self
+            .restore_outputs(
+                saga_id,
+                "Issue",
+                data.blinded_messages.as_deref(),
+                data.counter_start,
+                data.counter_end,
+            )
+            .await?;
+
+        match new_proofs {
+            Some(proofs) => {
+                // Issue has no input proofs to remove - just add the recovered proofs
+                self.localstore
+                    .update_proofs(proofs.clone(), vec![])
+                    .await?;
+
+                // Record transaction (best-effort, don't fail recovery if this fails)
+                if let Err(e) = self
+                    .record_recovered_issue_transaction(saga_id, &data.quote_id, &proofs)
+                    .await
+                {
+                    tracing::warn!(
+                        "Failed to record transaction for recovered issue saga {}: {}",
+                        saga_id,
+                        e
+                    );
+                }
+
+                self.localstore.delete_saga(saga_id).await?;
+                Ok(RecoveryAction::Recovered)
+            }
+            None => {
+                // Couldn't restore outputs - issue saga has no inputs to mark spent
+                tracing::warn!(
+                    "Issue saga {} - couldn't restore outputs. \
+                     Run wallet.restore() to recover any missing proofs.",
+                    saga_id
+                );
+                self.localstore.delete_saga(saga_id).await?;
+                Ok(RecoveryAction::Compensated)
+            }
+        }
+    }
+
+    /// Record a transaction for recovered issue proofs.
+    /// Skipped if quote not found (recovery still succeeds).
+    async fn record_recovered_issue_transaction(
+        &self,
+        saga_id: &uuid::Uuid,
+        quote_id: &str,
+        proofs: &[ProofInfo],
+    ) -> Result<(), Error> {
+        // Get and update quote state from mint
+        let quote = match self.localstore.get_mint_quote(quote_id).await? {
+            Some(mut q) => {
+                // Update state from mint
+                if let Err(e) = self.check_state(&mut q).await {
+                    tracing::warn!(
+                        "Failed to check quote state for transaction recording: {}",
+                        e
+                    );
+                }
+                // Save updated quote state
+                if let Err(e) = self.localstore.add_mint_quote(q.clone()).await {
+                    tracing::warn!("Failed to save updated quote state: {}", e);
+                }
+                q
+            }
+            None => {
+                tracing::warn!(
+                    "Issue saga {} - quote {} not found, skipping transaction recording",
+                    saga_id,
+                    quote_id
+                );
+                return Ok(());
+            }
+        };
+
+        let minted_amount = proofs
+            .iter()
+            .fold(Amount::ZERO, |acc, p| acc + p.proof.amount);
+        let ys: Vec<_> = proofs.iter().map(|p| p.y).collect();
+
+        self.localstore
+            .add_transaction(Transaction {
+                mint_url: self.mint_url.clone(),
+                direction: TransactionDirection::Incoming,
+                amount: minted_amount,
+                fee: Amount::ZERO,
+                unit: self.unit.clone(),
+                ys,
+                timestamp: unix_time(),
+                memo: None,
+                metadata: HashMap::new(),
+                quote_id: Some(quote_id.to_string()),
+                payment_request: Some(quote.request.clone()),
+                payment_proof: None,
+                payment_method: Some(quote.payment_method.clone()),
+                saga_id: Some(*saga_id),
+            })
+            .await?;
+
+        Ok(())
+    }
+
+    /// Attempt to replay the original mint request.
+    ///
+    /// This leverages NUT-19 caching: if the mint has a cached response for this
+    /// exact request, it will return the signatures immediately.
+    ///
+    /// Returns:
+    /// - `Ok(Some(proofs))` if replay succeeded and we got signatures
+    /// - `Ok(None)` if replay failed (fall back to /restore)
+    /// - `Err` only for unrecoverable errors
+    async fn try_replay_mint(
+        &self,
+        saga_id: &uuid::Uuid,
+        data: &MintOperationData,
+    ) -> Result<Option<Vec<ProofInfo>>, Error> {
+        // We need blinded messages to reconstruct the request
+        let blinded_messages = match &data.blinded_messages {
+            Some(bm) if !bm.is_empty() => bm,
+            _ => {
+                tracing::debug!(
+                    "Issue saga {} - no blinded messages stored, cannot replay",
+                    saga_id
+                );
+                return Ok(None);
+            }
+        };
+
+        // Get the mint quote to retrieve payment method and potentially sign the request
+        let quote = match self.localstore.get_mint_quote(&data.quote_id).await? {
+            Some(q) => q,
+            None => {
+                tracing::debug!(
+                    "Issue saga {} - mint quote not found, cannot replay",
+                    saga_id
+                );
+                return Ok(None);
+            }
+        };
+
+        // Construct the mint request
+        let mut mint_request: MintRequest<String> = MintRequest {
+            quote: data.quote_id.clone(),
+            outputs: blinded_messages.clone(),
+            signature: None,
+        };
+
+        // Sign the request if the quote has a secret key (required for bolt12)
+        if let Some(ref secret_key) = quote.secret_key {
+            if let Err(e) = mint_request.sign(secret_key.clone()) {
+                tracing::warn!(
+                    "Issue saga {} - failed to sign mint request: {}, cannot replay",
+                    saga_id,
+                    e
+                );
+                return Ok(None);
+            }
+        }
+
+        tracing::info!(
+            "Issue saga {} - attempting replay of post_mint request",
+            saga_id
+        );
+
+        // Attempt the replay
+        let mint_response = match self
+            .client
+            .post_mint(&quote.payment_method, mint_request)
+            .await
+        {
+            Ok(response) => response,
+            Err(e) => {
+                tracing::info!(
+                    "Issue saga {} - replay failed ({}), falling back to restore",
+                    saga_id,
+                    e
+                );
+                return Ok(None);
+            }
+        };
+
+        // Replay succeeded - construct proofs from signatures
+        tracing::info!(
+            "Issue saga {} - replay succeeded, got {} signatures",
+            saga_id,
+            mint_response.signatures.len()
+        );
+
+        // We need to re-derive the secrets to unblind the signatures
+        let (counter_start, counter_end) = match (data.counter_start, data.counter_end) {
+            (Some(start), Some(end)) => (start, end),
+            _ => {
+                tracing::warn!(
+                    "Issue saga {} - no counter range stored, cannot construct proofs",
+                    saga_id
+                );
+                return Ok(None);
+            }
+        };
+
+        let keyset_id = blinded_messages[0].keyset_id;
+
+        let premint_secrets = crate::nuts::PreMintSecrets::restore_batch(
+            keyset_id,
+            &self.seed,
+            counter_start,
+            counter_end,
+        )?;
+
+        let keys = self.load_keyset_keys(keyset_id).await?;
+
+        let proofs = construct_proofs(
+            mint_response.signatures,
+            premint_secrets.rs(),
+            premint_secrets.secrets(),
+            &keys,
+        )?;
+
+        let proof_infos: Vec<ProofInfo> = proofs
+            .into_iter()
+            .map(|p| ProofInfo::new(p, self.mint_url.clone(), State::Unspent, self.unit.clone()))
+            .collect::<Result<Vec<_>, _>>()?;
+
+        Ok(Some(proof_infos))
+    }
+
+    /// Compensate an issue saga by releasing the quote and deleting the saga.
+    async fn compensate_issue(&self, saga_id: &uuid::Uuid) -> Result<(), Error> {
+        // Release the mint quote reservation (best-effort, continue on error)
+        if let Err(e) = (ReleaseMintQuote {
+            localstore: self.localstore.clone(),
+            operation_id: *saga_id,
+        }
+        .execute()
+        .await)
+        {
+            tracing::warn!(
+                "Failed to release mint quote for saga {}: {}. Continuing with saga cleanup.",
+                saga_id,
+                e
+            );
+        }
+
+        self.localstore.delete_saga(saga_id).await?;
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::sync::Arc;
+
+    use cdk_common::nuts::{CurrencyUnit, RestoreResponse};
+    use cdk_common::wallet::{
+        IssueSagaState, MintOperationData, OperationData, WalletSaga, WalletSagaState,
+    };
+    use cdk_common::Amount;
+
+    use crate::wallet::recovery::RecoveryAction;
+    use crate::wallet::saga::test_utils::{create_test_db, test_mint_url};
+    use crate::wallet::test_utils::{
+        create_test_wallet_with_mock, test_mint_quote, MockMintConnector,
+    };
+
+    #[tokio::test]
+    async fn test_recover_issue_secrets_prepared() {
+        // Compensate: quote released
+        let db = create_test_db().await;
+        let mint_url = test_mint_url();
+        let saga_id = uuid::Uuid::new_v4();
+        let quote_id = format!("test_mint_quote_{}", uuid::Uuid::new_v4());
+
+        // Store mint quote before reserving it
+        let mut mint_quote = test_mint_quote(mint_url.clone());
+        mint_quote.id = quote_id.clone(); // Use our specific quote ID
+        db.add_mint_quote(mint_quote).await.unwrap();
+
+        // Reserve mint quote
+        db.reserve_mint_quote(&quote_id, &saga_id).await.unwrap();
+
+        // Create saga in SecretsPrepared state
+        let saga = WalletSaga::new(
+            saga_id,
+            WalletSagaState::Issue(IssueSagaState::SecretsPrepared),
+            Amount::from(1000),
+            mint_url.clone(),
+            CurrencyUnit::Sat,
+            OperationData::Mint(MintOperationData {
+                quote_id: quote_id.clone(),
+                amount: Amount::from(1000),
+                blinded_messages: None,
+                counter_start: None,
+                counter_end: None,
+            }),
+        );
+        db.add_saga(saga).await.unwrap();
+
+        // Create wallet and recover
+        let mock_client = Arc::new(MockMintConnector::new());
+        let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await;
+        let result = wallet
+            .resume_issue_saga(&db.get_saga(&saga_id).await.unwrap().unwrap())
+            .await;
+
+        // Verify compensation
+        assert!(result.is_ok());
+        let recovery_action = result.unwrap();
+        assert_eq!(recovery_action, RecoveryAction::Compensated);
+
+        // Saga should be deleted
+        assert!(db.get_saga(&saga_id).await.unwrap().is_none());
+    }
+
+    #[tokio::test]
+    async fn test_recover_issue_mint_requested_replay_succeeds() {
+        // Mock: post_mint succeeds → recovered
+        let db = create_test_db().await;
+        let mint_url = test_mint_url();
+        let saga_id = uuid::Uuid::new_v4();
+        let quote_id = format!("test_mint_quote_{}", uuid::Uuid::new_v4());
+
+        // Create saga in MintRequested state
+        let saga = WalletSaga::new(
+            saga_id,
+            WalletSagaState::Issue(IssueSagaState::MintRequested),
+            Amount::from(1000),
+            mint_url.clone(),
+            CurrencyUnit::Sat,
+            OperationData::Mint(MintOperationData {
+                quote_id: quote_id.clone(),
+                amount: Amount::from(1000),
+                blinded_messages: Some(vec![]), // Empty for simplicity
+                counter_start: Some(0),
+                counter_end: Some(10),
+            }),
+        );
+        db.add_saga(saga).await.unwrap();
+
+        // Store mint quote
+        let mint_quote = test_mint_quote(mint_url.clone());
+        db.add_mint_quote(mint_quote).await.unwrap();
+
+        // Mock: post_mint succeeds
+        let mock_client = Arc::new(MockMintConnector::new());
+        mock_client.set_post_mint_response(Ok(crate::nuts::MintResponse { signatures: vec![] }));
+
+        let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await;
+        let result = wallet
+            .resume_issue_saga(&db.get_saga(&saga_id).await.unwrap().unwrap())
+            .await;
+
+        // Verify recovery
+        assert!(result.is_ok());
+        let recovery_action = result.unwrap();
+
+        // With empty blinded_messages, falls back to restore
+        // With empty restore response, saga deleted as Compensated
+        assert_eq!(recovery_action, RecoveryAction::Compensated);
+        assert!(db.get_saga(&saga_id).await.unwrap().is_none());
+
+        // No proofs created
+        let proofs = db.get_proofs(None, None, None, None).await.unwrap();
+        assert!(proofs.is_empty());
+
+        // No transaction recorded for compensated issue
+        let transactions = db.list_transactions(None, None, None).await.unwrap();
+        assert!(transactions.is_empty());
+    }
+
+    #[tokio::test]
+    async fn test_recover_issue_mint_requested_restore_succeeds() {
+        // Mock: post_mint fails, restore succeeds → recovered
+        let db = create_test_db().await;
+        let mint_url = test_mint_url();
+        let saga_id = uuid::Uuid::new_v4();
+        let quote_id = format!("test_mint_quote_{}", uuid::Uuid::new_v4());
+
+        // Create saga in MintRequested state
+        let saga = WalletSaga::new(
+            saga_id,
+            WalletSagaState::Issue(IssueSagaState::MintRequested),
+            Amount::from(1000),
+            mint_url.clone(),
+            CurrencyUnit::Sat,
+            OperationData::Mint(MintOperationData {
+                quote_id: quote_id.clone(),
+                amount: Amount::from(1000),
+                blinded_messages: Some(vec![]),
+                counter_start: Some(0),
+                counter_end: Some(10),
+            }),
+        );
+        db.add_saga(saga).await.unwrap();
+
+        // Store mint quote
+        let mint_quote = test_mint_quote(mint_url.clone());
+        db.add_mint_quote(mint_quote).await.unwrap();
+
+        // Mock: post_mint fails, restore returns proofs
+        let mock_client = Arc::new(MockMintConnector::new());
+        mock_client.set_post_mint_response(Err(crate::Error::Custom("Mint failed".to_string())));
+        mock_client._set_restore_response(Ok(RestoreResponse {
+            signatures: vec![],
+            outputs: vec![],
+            promises: None,
+        }));
+
+        let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await;
+        let result = wallet
+            .resume_issue_saga(&db.get_saga(&saga_id).await.unwrap().unwrap())
+            .await;
+
+        // Verify recovery
+        assert!(result.is_ok());
+        let recovery_action = result.unwrap();
+
+        // post_mint fails, restore returns empty -> Compensated
+        assert_eq!(recovery_action, RecoveryAction::Compensated);
+        assert!(db.get_saga(&saga_id).await.unwrap().is_none());
+
+        // No proofs
+        let proofs = db.get_proofs(None, None, None, None).await.unwrap();
+        assert!(proofs.is_empty());
+
+        // No transaction for compensated issue
+        let transactions = db.list_transactions(None, None, None).await.unwrap();
+        assert!(transactions.is_empty());
+    }
+}

+ 49 - 0
crates/cdk/src/wallet/issue/saga/state.rs

@@ -0,0 +1,49 @@
+//! State types for the Mint (Issue) saga.
+//!
+//! Each state is a distinct type that holds the data relevant to that stage
+//! of the mint operation. The type state pattern ensures that only valid
+//! operations are available at each stage.
+
+use cdk_common::wallet::WalletSaga;
+use uuid::Uuid;
+
+use crate::nuts::{Id, PaymentMethod, PreMintSecrets, Proofs};
+use crate::wallet::MintQuote;
+
+/// Type alias for MintRequest with String quote ID
+pub type MintRequestString = crate::nuts::MintRequest<String>;
+
+/// Initial state - operation ID assigned, no work done yet.
+#[derive(Debug)]
+pub struct Initial {
+    /// Unique operation identifier for tracking and crash recovery
+    pub operation_id: Uuid,
+}
+
+/// Prepared state - quote validated, premint secrets created, ready to execute.
+#[derive(Debug)]
+pub struct Prepared {
+    /// Unique operation identifier
+    pub operation_id: Uuid,
+    /// Quote ID being minted
+    pub quote_id: String,
+    /// Quote information
+    pub quote_info: MintQuote,
+    /// Active keyset ID
+    pub active_keyset_id: Id,
+    /// Premint secrets
+    pub premint_secrets: PreMintSecrets,
+    /// Mint request ready to send
+    pub mint_request: MintRequestString,
+    /// Payment method (Bolt11 or Bolt12)
+    pub payment_method: PaymentMethod,
+    /// Persisted saga for optimistic locking and recovery
+    pub saga: WalletSaga,
+}
+
+/// Finalized state - mint completed successfully, proofs available.
+#[derive(Debug)]
+pub struct Finalized {
+    /// Minted proofs
+    pub proofs: Proofs,
+}

+ 8 - 448
crates/cdk/src/wallet/melt/bolt11.rs

@@ -1,52 +1,18 @@
-use std::collections::HashMap;
 use std::str::FromStr;
 
-use cdk_common::amount::SplitTarget;
 use cdk_common::nut00::KnownMethod;
-use cdk_common::wallet::{Transaction, TransactionDirection};
+use cdk_common::wallet::MeltQuote;
 use cdk_common::PaymentMethod;
 use lightning_invoice::Bolt11Invoice;
 use tracing::instrument;
 
-use crate::dhke::construct_proofs;
-use crate::nuts::nut00::ProofsMethods;
-use crate::nuts::{
-    CurrencyUnit, MeltOptions, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltRequest,
-    PreMintSecrets, Proofs, State,
-};
-use crate::types::{Melted, ProofInfo};
-use crate::util::unix_time;
-use crate::wallet::send::split_proofs_for_send;
-use crate::wallet::MeltQuote;
-use crate::{ensure_cdk, Amount, Error, Wallet};
+use crate::nuts::{CurrencyUnit, MeltOptions, MeltQuoteBolt11Request};
+use crate::{Amount, Error, Wallet};
 
 impl Wallet {
-    /// Melt Quote
-    /// # Synopsis
-    /// ```rust,no_run
-    ///  use std::sync::Arc;
-    ///
-    ///  use cdk_sqlite::wallet::memory;
-    ///  use cdk::nuts::CurrencyUnit;
-    ///  use cdk::wallet::Wallet;
-    ///  use rand::random;
-    ///
-    /// #[tokio::main]
-    /// async fn main() -> anyhow::Result<()> {
-    ///     let seed = random::<[u8; 64]>();
-    ///     let mint_url = "https://fake.thesimplekid.dev";
-    ///     let unit = CurrencyUnit::Sat;
-    ///
-    ///     let localstore = memory::empty().await?;
-    ///     let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None).unwrap();
-    ///     let bolt11 = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq".to_string();
-    ///     let quote = wallet.melt_quote(bolt11, None).await?;
-    ///
-    ///     Ok(())
-    /// }
-    /// ```
+    /// Melt Quote for Bolt11
     #[instrument(skip(self, request))]
-    pub async fn melt_quote(
+    pub(crate) async fn melt_bolt11_quote(
         &self,
         request: String,
         options: Option<MeltOptions>,
@@ -81,6 +47,7 @@ impl Wallet {
             }
         }
 
+        // Construct MeltQuote from response
         let quote = MeltQuote {
             id: quote_res.quote,
             amount: quote_res.amount,
@@ -91,419 +58,12 @@ impl Wallet {
             expiry: quote_res.expiry,
             payment_preimage: quote_res.payment_preimage,
             payment_method: PaymentMethod::Known(KnownMethod::Bolt11),
+            used_by_operation: None,
+            version: 0,
         };
 
         self.localstore.add_melt_quote(quote.clone()).await?;
 
         Ok(quote)
     }
-
-    /// Melt quote status
-    #[instrument(skip(self, quote_id))]
-    pub async fn melt_quote_status(
-        &self,
-        quote_id: &str,
-    ) -> Result<MeltQuoteBolt11Response<String>, Error> {
-        let response = self.client.get_melt_quote_status(quote_id).await?;
-
-        match self.localstore.get_melt_quote(quote_id).await? {
-            Some(quote) => {
-                let mut quote = quote;
-
-                if let Err(e) = self
-                    .add_transaction_for_pending_melt(&quote, &response)
-                    .await
-                {
-                    tracing::error!("Failed to add transaction for pending melt: {}", e);
-                }
-
-                quote.state = response.state;
-                self.localstore.add_melt_quote(quote).await?;
-            }
-            None => {
-                tracing::info!("Quote melt {} unknown", quote_id);
-            }
-        }
-
-        Ok(response)
-    }
-
-    /// Melt specific proofs
-    #[instrument(skip(self, proofs))]
-    pub async fn melt_proofs(&self, quote_id: &str, proofs: Proofs) -> Result<Melted, Error> {
-        self.melt_proofs_with_metadata(quote_id, proofs, HashMap::new())
-            .await
-    }
-
-    /// Melt specific proofs
-    #[instrument(skip(self, proofs))]
-    pub async fn melt_proofs_with_metadata(
-        &self,
-        quote_id: &str,
-        proofs: Proofs,
-        metadata: HashMap<String, String>,
-    ) -> Result<Melted, Error> {
-        let active_keyset_id = self.fetch_active_keyset().await?.id;
-        let mut quote_info = self
-            .localstore
-            .get_melt_quote(quote_id)
-            .await?
-            .ok_or(Error::UnknownQuote)?;
-
-        ensure_cdk!(
-            quote_info.expiry.gt(&unix_time()),
-            Error::ExpiredQuote(quote_info.expiry, unix_time())
-        );
-
-        let proofs_total = proofs.total_amount()?;
-        if proofs_total < quote_info.amount + quote_info.fee_reserve {
-            return Err(Error::InsufficientFunds);
-        }
-
-        // Since the proofs may be external (not in our database), add them first
-        let proofs_info = proofs
-            .clone()
-            .into_iter()
-            .map(|p| ProofInfo::new(p, self.mint_url.clone(), State::Pending, self.unit.clone()))
-            .collect::<Result<Vec<ProofInfo>, _>>()?;
-
-        self.localstore.update_proofs(proofs_info, vec![]).await?;
-
-        // Calculate change accounting for input fees
-        // The mint deducts input fees from available funds before calculating change
-        let input_fee = self.get_proofs_fee(&proofs).await?.total;
-        let change_amount = proofs_total - quote_info.amount - input_fee;
-
-        let premint_secrets = if change_amount <= Amount::ZERO {
-            PreMintSecrets::new(active_keyset_id)
-        } else {
-            // TODO: consolidate this calculation with from_seed_blank into a shared function
-            // Calculate how many secrets will be needed using the same logic as from_seed_blank
-            let num_secrets =
-                ((u64::from(change_amount) as f64).log2().ceil() as u64).max(1) as u32;
-
-            tracing::debug!(
-                "Incrementing keyset {} counter by {}",
-                active_keyset_id,
-                num_secrets
-            );
-
-            // Atomically get the counter range we need
-            let new_counter = self
-                .localstore
-                .increment_keyset_counter(&active_keyset_id, num_secrets)
-                .await?;
-
-            let count = new_counter - num_secrets;
-
-            PreMintSecrets::from_seed_blank(active_keyset_id, count, &self.seed, change_amount)?
-        };
-
-        let request = MeltRequest::new(
-            quote_id.to_string(),
-            proofs.clone(),
-            Some(premint_secrets.blinded_messages()),
-        );
-
-        let melt_response = self
-            .try_proof_operation_or_reclaim(
-                request.inputs().clone(),
-                self.client.post_melt(&quote_info.payment_method, request),
-            )
-            .await?;
-
-        let active_keys = self.load_keyset_keys(active_keyset_id).await?;
-
-        let change_proofs = match melt_response.change {
-            Some(change) => {
-                let num_change_proof = change.len();
-
-                let num_change_proof = match (
-                    premint_secrets.len() < num_change_proof,
-                    premint_secrets.secrets().len() < num_change_proof,
-                ) {
-                    (true, _) | (_, true) => {
-                        tracing::error!("Mismatch in change promises to change");
-                        premint_secrets.len()
-                    }
-                    _ => num_change_proof,
-                };
-
-                Some(construct_proofs(
-                    change,
-                    premint_secrets.rs()[..num_change_proof].to_vec(),
-                    premint_secrets.secrets()[..num_change_proof].to_vec(),
-                    &active_keys,
-                )?)
-            }
-            None => None,
-        };
-
-        let payment_preimage = melt_response.payment_preimage.clone();
-        let state = melt_response.state;
-
-        let melted = Melted::from_proofs(
-            state,
-            payment_preimage.clone(),
-            quote_info.amount,
-            proofs.clone(),
-            change_proofs.clone(),
-        )?;
-
-        let change_proof_infos = match change_proofs {
-            Some(change_proofs) => {
-                tracing::debug!(
-                    "Change amount returned from melt: {}",
-                    change_proofs.total_amount()?
-                );
-
-                change_proofs
-                    .into_iter()
-                    .map(|proof| {
-                        ProofInfo::new(
-                            proof,
-                            self.mint_url.clone(),
-                            State::Unspent,
-                            quote_info.unit.clone(),
-                        )
-                    })
-                    .collect::<Result<Vec<ProofInfo>, _>>()?
-            }
-            None => Vec::new(),
-        };
-
-        quote_info.state = cdk_common::MeltQuoteState::Paid;
-
-        let payment_request = quote_info.request.clone();
-        let payment_method = quote_info.payment_method.clone();
-        self.localstore.add_melt_quote(quote_info).await?;
-
-        let deleted_ys = proofs.ys()?;
-
-        self.localstore
-            .update_proofs(change_proof_infos, deleted_ys)
-            .await?;
-
-        // Add transaction to store
-        self.localstore
-            .add_transaction(Transaction {
-                mint_url: self.mint_url.clone(),
-                direction: TransactionDirection::Outgoing,
-                amount: melted.amount,
-                fee: melted.fee_paid,
-                unit: self.unit.clone(),
-                ys: proofs.ys()?,
-                timestamp: unix_time(),
-                memo: None,
-                metadata,
-                quote_id: Some(quote_id.to_string()),
-                payment_request: Some(payment_request),
-                payment_proof: payment_preimage,
-                payment_method: Some(payment_method),
-            })
-            .await?;
-
-        Ok(melted)
-    }
-
-    /// Melt
-    /// # Synopsis
-    /// ```rust, no_run
-    ///  use std::sync::Arc;
-    ///
-    ///  use cdk_sqlite::wallet::memory;
-    ///  use cdk::nuts::CurrencyUnit;
-    ///  use cdk::wallet::Wallet;
-    ///  use rand::random;
-    ///
-    /// #[tokio::main]
-    /// async fn main() -> anyhow::Result<()> {
-    ///  let seed = random::<[u8; 64]>();
-    ///  let mint_url = "https://fake.thesimplekid.dev";
-    ///  let unit = CurrencyUnit::Sat;
-    ///
-    ///  let localstore = memory::empty().await?;
-    ///  let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None).unwrap();
-    ///  let bolt11 = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq".to_string();
-    ///  let quote = wallet.melt_quote(bolt11, None).await?;
-    ///  let quote_id = quote.id;
-    ///
-    ///  let _ = wallet.melt(&quote_id).await?;
-    ///
-    ///  Ok(())
-    /// }
-    #[instrument(skip(self))]
-    pub async fn melt(&self, quote_id: &str) -> Result<Melted, Error> {
-        self.melt_with_metadata(quote_id, HashMap::new()).await
-    }
-
-    /// Melt with additional metadata to be saved locally with the transaction
-    /// # Synopsis
-    /// ```rust, no_run
-    ///  use std::sync::Arc;
-    ///
-    ///  use cdk_sqlite::wallet::memory;
-    ///  use cdk::nuts::CurrencyUnit;
-    ///  use cdk::wallet::Wallet;
-    ///  use rand::random;
-    ///
-    /// #[tokio::main]
-    /// async fn main() -> anyhow::Result<()> {
-    ///  let seed = random::<[u8; 64]>();
-    ///  let mint_url = "https://fake.thesimplekid.dev";
-    ///  let unit = CurrencyUnit::Sat;
-    ///
-    ///  let localstore = memory::empty().await?;
-    ///  let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None).unwrap();
-    ///  let bolt11 = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq".to_string();
-    ///  let quote = wallet.melt_quote(bolt11, None).await?;
-    ///  let quote_id = quote.id;
-    ///
-    ///  let mut metadata = std::collections::HashMap::new();
-    ///  metadata.insert("my key".to_string(), "my value".to_string());
-    ///
-    ///  let _ = wallet.melt_with_metadata(&quote_id, metadata).await?;
-    ///
-    ///  Ok(())
-    /// }
-    #[instrument(skip(self))]
-    pub async fn melt_with_metadata(
-        &self,
-        quote_id: &str,
-        metadata: HashMap<String, String>,
-    ) -> Result<Melted, Error> {
-        let quote_info = self
-            .localstore
-            .get_melt_quote(quote_id)
-            .await?
-            .ok_or(Error::UnknownQuote)?;
-
-        ensure_cdk!(
-            quote_info.expiry.gt(&unix_time()),
-            Error::ExpiredQuote(quote_info.expiry, unix_time())
-        );
-
-        let inputs_needed_amount = quote_info.amount + quote_info.fee_reserve;
-
-        let active_keyset_ids = self
-            .get_mint_keysets()
-            .await?
-            .into_iter()
-            .map(|k| k.id)
-            .collect();
-        let keyset_fees_and_amounts = self.get_keyset_fees_and_amounts().await?;
-
-        let available_proofs = self.get_unspent_proofs().await?;
-
-        // Two-step proof selection for melt:
-        // Step 1: Try to select proofs that exactly match inputs_needed_amount.
-        //         If successful, no swap is required and we avoid paying swap fees.
-        // Step 2: If exact match not possible, we need to swap to get optimal denominations.
-        //         In this case, we must select more proofs to cover the additional swap fees.
-        {
-            let input_proofs = Wallet::select_proofs(
-                inputs_needed_amount,
-                available_proofs.clone(),
-                &active_keyset_ids,
-                &keyset_fees_and_amounts,
-                true,
-            )?;
-            let proofs_total = input_proofs.total_amount()?;
-
-            // If exact match, use proofs directly without swap
-            if proofs_total == inputs_needed_amount {
-                return self
-                    .melt_proofs_with_metadata(quote_id, input_proofs, metadata)
-                    .await;
-            }
-        }
-
-        let active_keyset_id = self.get_active_keyset().await?.id;
-        let fee_and_amounts = self
-            .get_keyset_fees_and_amounts_by_id(active_keyset_id)
-            .await?;
-
-        // Calculate optimal denomination split and the fee for those proofs
-        // First estimate based on inputs_needed_amount to get target_fee
-        let initial_split = inputs_needed_amount.split(&fee_and_amounts)?;
-        let target_fee = self
-            .get_proofs_fee_by_count(
-                vec![(active_keyset_id, initial_split.len() as u64)]
-                    .into_iter()
-                    .collect(),
-            )
-            .await?
-            .total;
-
-        // Since we could not select the correct inputs amount needed for melting,
-        // we select again this time including the amount we will now have to pay as a fee for the swap.
-        let inputs_total_needed = inputs_needed_amount + target_fee;
-
-        // Recalculate target amounts based on the actual total we need (including fee)
-        let target_amounts = inputs_total_needed.split(&fee_and_amounts)?;
-        let input_proofs = Wallet::select_proofs(
-            inputs_total_needed,
-            available_proofs,
-            &active_keyset_ids,
-            &keyset_fees_and_amounts,
-            true,
-        )?;
-        let proofs_total = input_proofs.total_amount()?;
-
-        // Need to swap to get exact denominations
-        tracing::debug!(
-            "Proofs total {} != inputs needed {}, swapping to get exact amount",
-            proofs_total,
-            inputs_total_needed
-        );
-
-        let keyset_fees: HashMap<cdk_common::Id, u64> = keyset_fees_and_amounts
-            .iter()
-            .map(|(key, values)| (*key, values.fee()))
-            .collect();
-
-        let split_result = split_proofs_for_send(
-            input_proofs,
-            &target_amounts,
-            inputs_total_needed,
-            target_fee,
-            &keyset_fees,
-            false,
-            false,
-        )?;
-
-        let mut final_proofs = split_result.proofs_to_send;
-
-        if !split_result.proofs_to_swap.is_empty() {
-            let swap_amount = inputs_total_needed
-                .checked_sub(final_proofs.total_amount()?)
-                .ok_or(Error::AmountOverflow)?;
-
-            tracing::debug!(
-                "Swapping {} proofs to get {} sats (swap fee: {} sats)",
-                split_result.proofs_to_swap.len(),
-                swap_amount,
-                split_result.swap_fee
-            );
-
-            if let Some(swapped) = self
-                .try_proof_operation_or_reclaim(
-                    split_result.proofs_to_swap.clone(),
-                    self.swap(
-                        Some(swap_amount),
-                        SplitTarget::None,
-                        split_result.proofs_to_swap,
-                        None,
-                        false, // fees already accounted for in inputs_total_needed
-                    ),
-                )
-                .await?
-            {
-                final_proofs.extend(swapped);
-            }
-        }
-
-        self.melt_proofs_with_metadata(quote_id, final_proofs, metadata)
-            .await
-    }
 }

+ 4 - 32
crates/cdk/src/wallet/melt/bolt12.rs

@@ -11,13 +11,13 @@ use cdk_common::PaymentMethod;
 use lightning::offers::offer::Offer;
 use tracing::instrument;
 
-use crate::nuts::{CurrencyUnit, MeltOptions, MeltQuoteBolt11Response, MeltQuoteBolt12Request};
+use crate::nuts::{CurrencyUnit, MeltOptions, MeltQuoteBolt12Request};
 use crate::{Amount, Error, Wallet};
 
 impl Wallet {
     /// Melt Quote for BOLT12 offer
     #[instrument(skip(self, request))]
-    pub async fn melt_bolt12_quote(
+    pub(crate) async fn melt_bolt12_quote(
         &self,
         request: String,
         options: Option<MeltOptions>,
@@ -61,40 +61,12 @@ impl Wallet {
             expiry: quote_res.expiry,
             payment_preimage: quote_res.payment_preimage,
             payment_method: PaymentMethod::Known(KnownMethod::Bolt12),
+            used_by_operation: None,
+            version: 0,
         };
 
         self.localstore.add_melt_quote(quote.clone()).await?;
 
         Ok(quote)
     }
-
-    /// BOLT12 melt quote status
-    #[instrument(skip(self, quote_id))]
-    pub async fn melt_bolt12_quote_status(
-        &self,
-        quote_id: &str,
-    ) -> Result<MeltQuoteBolt11Response<String>, Error> {
-        let response = self.client.get_melt_bolt12_quote_status(quote_id).await?;
-
-        match self.localstore.get_melt_quote(quote_id).await? {
-            Some(quote) => {
-                let mut quote = quote;
-
-                if let Err(e) = self
-                    .add_transaction_for_pending_melt(&quote, &response)
-                    .await
-                {
-                    tracing::error!("Failed to add transaction for pending melt: {}", e);
-                }
-
-                quote.state = response.state;
-                self.localstore.add_melt_quote(quote).await?;
-            }
-            None => {
-                tracing::info!("Quote melt {} unknown", quote_id);
-            }
-        }
-
-        Ok(response)
-    }
 }

+ 9 - 2
crates/cdk/src/wallet/melt/custom.rs

@@ -14,7 +14,7 @@ impl Wallet {
     /// * `_options` - Melt options (currently unused for custom methods)
     /// * `extra` - Optional extra payment-method-specific data as JSON
     #[instrument(skip(self, request, extra))]
-    pub(super) async fn melt_quote_custom(
+    pub(crate) async fn melt_quote_custom(
         &self,
         method: &str,
         request: String,
@@ -31,17 +31,24 @@ impl Wallet {
         };
         let quote_res = self.client.post_melt_custom_quote(quote_request).await?;
 
+        // Construct MeltQuote from custom response
+        // Use response's request if present, otherwise fallback to input request
+        let quote_request_str = quote_res.request.unwrap_or(request);
+
         let quote = MeltQuote {
             id: quote_res.quote,
             amount: quote_res.amount,
-            request,
+            request: quote_request_str,
             unit: self.unit.clone(),
             fee_reserve: quote_res.fee_reserve,
             state: quote_res.state,
             expiry: quote_res.expiry,
             payment_preimage: quote_res.payment_preimage,
             payment_method: PaymentMethod::Custom(method.to_string()),
+            used_by_operation: None,
+            version: 0,
         };
+
         self.localstore.add_melt_quote(quote.clone()).await?;
 
         Ok(quote)

+ 1 - 1
crates/cdk/src/wallet/melt/melt_lightning_address.rs

@@ -85,6 +85,6 @@ impl Wallet {
 
         // Create a melt quote for the invoice using the existing bolt11 functionality
         // The invoice from LNURL already contains the amount, so we don't need amountless options
-        self.melt_quote(invoice.to_string(), None).await
+        self.melt_bolt11_quote(invoice.to_string(), None).await
     }
 }

+ 662 - 36
crates/cdk/src/wallet/melt/mod.rs

@@ -1,15 +1,52 @@
+//! Melt Module
+//!
+//! This module provides the melt functionality for the wallet.
+//!
+//! # Usage
+//!
+//! Use [`Wallet::prepare_melt`] to create a [`PreparedMelt`], then call
+//! [`confirm`](PreparedMelt::confirm) to complete the melt or
+//! [`cancel`](PreparedMelt::cancel) to release reserved proofs.
+//!
+//! ```rust,no_run
+//! # async fn example(wallet: &cdk::wallet::Wallet) -> anyhow::Result<()> {
+//! use std::collections::HashMap;
+//!
+//! use cdk::nuts::PaymentMethod;
+//! let quote = wallet
+//!     .melt_quote(PaymentMethod::BOLT11, "lnbc...", None, None)
+//!     .await?;
+//!
+//! // Prepare the melt - proofs are reserved but payment not yet executed
+//! let prepared = wallet.prepare_melt(&quote.id, HashMap::new()).await?;
+//!
+//! // Inspect the prepared melt
+//! println!(
+//!     "Amount: {}, Fee: {}",
+//!     prepared.amount(),
+//!     prepared.total_fee()
+//! );
+//!
+//! // Either confirm or cancel
+//! let confirmed = prepared.confirm().await?;
+//! // Or: prepared.cancel().await?;
+//! # Ok(())
+//! # }
+//! ```
+
 use std::collections::HashMap;
+use std::fmt::Debug;
 
 use cdk_common::util::unix_time;
 use cdk_common::wallet::{MeltQuote, Transaction, TransactionDirection};
-use cdk_common::{
-    Error, MeltQuoteBolt11Response, MeltQuoteState, PaymentMethod, ProofsMethods, State,
-};
+use cdk_common::{Error, MeltQuoteState, PaymentMethod, ProofsMethods, State};
 use tracing::instrument;
+use uuid::Uuid;
 
 use crate::nuts::nut00::KnownMethod;
-use crate::nuts::MeltOptions;
-use crate::Wallet;
+use crate::nuts::{MeltOptions, Proofs};
+use crate::types::FinalizedMelt;
+use crate::{Amount, Wallet};
 
 mod bolt11;
 mod bolt12;
@@ -18,15 +55,397 @@ mod custom;
 mod melt_bip353;
 #[cfg(feature = "wallet")]
 mod melt_lightning_address;
+pub(crate) mod saga;
+
+use saga::state::Prepared;
+use saga::MeltSaga;
+
+/// Internal response type for melt quote status checking.
+///
+/// Wraps the different response types (Bolt11/Bolt12 vs Custom) that have
+/// identical fields but different Rust types.
+#[derive(Debug, Clone)]
+pub(crate) enum MeltQuoteStatusResponse {
+    /// Standard response (Bolt11/Bolt12)
+    Standard(cdk_common::MeltQuoteBolt11Response<String>),
+    /// Custom payment method response
+    Custom(cdk_common::MeltQuoteCustomResponse<String>),
+}
+
+impl MeltQuoteStatusResponse {
+    /// Get the quote state
+    pub fn state(&self) -> MeltQuoteState {
+        match self {
+            Self::Standard(r) => r.state,
+            Self::Custom(r) => r.state,
+        }
+    }
+
+    /// Get the payment preimage
+    pub fn payment_preimage(&self) -> Option<String> {
+        match self {
+            Self::Standard(r) => r.payment_preimage.clone(),
+            Self::Custom(r) => r.payment_preimage.clone(),
+        }
+    }
+
+    /// Convert to standard response (for Bolt11/Bolt12).
+    /// Returns error for Custom payment methods.
+    pub fn into_standard(self) -> Result<cdk_common::MeltQuoteBolt11Response<String>, Error> {
+        match self {
+            Self::Standard(r) => Ok(r),
+            Self::Custom(_) => Err(Error::Custom(
+                "Cannot convert custom response to standard response".to_string(),
+            )),
+        }
+    }
+}
+
+/// Options for confirming a melt operation
+#[derive(Debug, Clone, Default)]
+pub struct MeltConfirmOptions {
+    /// Skip the pre-melt swap and send proofs directly to melt.
+    pub skip_swap: bool,
+}
+
+impl MeltConfirmOptions {
+    /// Create options with default settings (swap enabled)
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Create options that skip the swap
+    pub fn skip_swap() -> Self {
+        Self { skip_swap: true }
+    }
+}
+
+/// A prepared melt operation that can be confirmed or cancelled.
+pub struct PreparedMelt<'a> {
+    /// The saga in the Prepared state
+    saga: MeltSaga<'a, Prepared>,
+    /// Metadata for the transaction
+    metadata: HashMap<String, String>,
+}
+
+impl<'a> PreparedMelt<'a> {
+    /// Get the operation ID
+    pub fn operation_id(&self) -> Uuid {
+        self.saga.operation_id()
+    }
+
+    /// Get the quote
+    pub fn quote(&self) -> &MeltQuote {
+        self.saga.quote()
+    }
+
+    /// Get the amount to be melted
+    pub fn amount(&self) -> Amount {
+        self.saga.quote().amount
+    }
+
+    /// Get the proofs that will be used
+    pub fn proofs(&self) -> &Proofs {
+        self.saga.proofs()
+    }
+
+    /// Get the proofs that need to be swapped
+    pub fn proofs_to_swap(&self) -> &Proofs {
+        self.saga.proofs_to_swap()
+    }
+
+    /// Get the swap fee
+    pub fn swap_fee(&self) -> Amount {
+        self.saga.swap_fee()
+    }
+
+    /// Get the input fee
+    pub fn input_fee(&self) -> Amount {
+        self.saga.input_fee()
+    }
+
+    /// Get the total fee (with swap, if applicable)
+    pub fn total_fee(&self) -> Amount {
+        self.saga.swap_fee() + self.saga.input_fee()
+    }
+
+    /// Returns true if a swap would be performed (proofs_to_swap is not empty)
+    pub fn requires_swap(&self) -> bool {
+        !self.saga.proofs_to_swap().is_empty()
+    }
+
+    /// Get the total fee if swap is performed (current default behavior)
+    pub fn total_fee_with_swap(&self) -> Amount {
+        self.saga.swap_fee() + self.saga.input_fee()
+    }
+
+    /// Get the input fee if swap is skipped (fee on all proofs sent directly)
+    pub fn input_fee_without_swap(&self) -> Amount {
+        self.saga.input_fee_without_swap()
+    }
+
+    /// Get the fee savings from skipping the swap
+    pub fn fee_savings_without_swap(&self) -> Amount {
+        self.total_fee_with_swap()
+            .checked_sub(self.input_fee_without_swap())
+            .unwrap_or(Amount::ZERO)
+    }
+
+    /// Get the expected change amount if swap is skipped
+    pub fn change_amount_without_swap(&self) -> Amount {
+        let all_proofs_total = self.saga.proofs().total_amount().unwrap_or(Amount::ZERO)
+            + self
+                .saga
+                .proofs_to_swap()
+                .total_amount()
+                .unwrap_or(Amount::ZERO);
+        let quote = self.saga.quote();
+        let needed = quote.amount + quote.fee_reserve + self.input_fee_without_swap();
+        all_proofs_total.checked_sub(needed).unwrap_or(Amount::ZERO)
+    }
+
+    /// Confirm the prepared melt and execute the payment.
+    pub async fn confirm(self) -> Result<FinalizedMelt, Error> {
+        self.confirm_with_options(MeltConfirmOptions::default())
+            .await
+    }
+
+    /// Confirm the prepared melt with custom options.
+    pub async fn confirm_with_options(
+        self,
+        options: MeltConfirmOptions,
+    ) -> Result<FinalizedMelt, Error> {
+        let melt_requested = self.saga.request_melt_with_options(options).await?;
+
+        let finalized = melt_requested.execute(self.metadata).await?;
+
+        Ok(FinalizedMelt::new(
+            finalized.quote_id().to_string(),
+            finalized.state(),
+            finalized.payment_proof().map(|s| s.to_string()),
+            finalized.amount(),
+            finalized.fee_paid(),
+            finalized.into_change(),
+        ))
+    }
+
+    /// Cancel the prepared melt and release reserved proofs
+    pub async fn cancel(self) -> Result<(), Error> {
+        self.saga.cancel().await
+    }
+}
+
+impl Debug for PreparedMelt<'_> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("PreparedMelt")
+            .field("operation_id", &self.saga.operation_id())
+            .field("quote_id", &self.saga.quote().id)
+            .field("amount", &self.saga.quote().amount)
+            .field("total_fee", &self.total_fee())
+            .finish()
+    }
+}
 
 impl Wallet {
-    /// Check pending melt quotes
+    /// Prepare a melt operation without executing it.
+    #[instrument(skip(self, metadata))]
+    pub async fn prepare_melt(
+        &self,
+        quote_id: &str,
+        metadata: HashMap<String, String>,
+    ) -> Result<PreparedMelt<'_>, Error> {
+        let saga = MeltSaga::new(self);
+        let prepared_saga = saga.prepare(quote_id, metadata.clone()).await?;
+
+        Ok(PreparedMelt {
+            saga: prepared_saga,
+            metadata,
+        })
+    }
+
+    /// Prepare a melt operation with specific proofs.
+    #[instrument(skip(self, proofs, metadata))]
+    pub async fn prepare_melt_proofs(
+        &self,
+        quote_id: &str,
+        proofs: crate::nuts::Proofs,
+        metadata: HashMap<String, String>,
+    ) -> Result<PreparedMelt<'_>, Error> {
+        let saga = MeltSaga::new(self);
+        let prepared_saga = saga
+            .prepare_with_proofs(quote_id, proofs, metadata.clone())
+            .await?;
+
+        Ok(PreparedMelt {
+            saga: prepared_saga,
+            metadata,
+        })
+    }
+
+    /// Finalize pending melt operations.
     #[instrument(skip_all)]
-    pub async fn check_pending_melt_quotes(&self) -> Result<(), Error> {
-        let quotes = self.get_pending_melt_quotes().await?;
-        for quote in quotes {
-            self.melt_quote_status(&quote.id).await?;
+    pub async fn finalize_pending_melts(&self) -> Result<Vec<FinalizedMelt>, Error> {
+        use cdk_common::wallet::{MeltSagaState, WalletSagaState};
+
+        let sagas = self.localstore.get_incomplete_sagas().await?;
+
+        // Filter to only melt sagas in states that need checking
+        let melt_sagas: Vec<_> = sagas
+            .into_iter()
+            .filter(|s| {
+                matches!(
+                    &s.state,
+                    WalletSagaState::Melt(
+                        MeltSagaState::MeltRequested | MeltSagaState::PaymentPending
+                    )
+                )
+            })
+            .collect();
+
+        if melt_sagas.is_empty() {
+            return Ok(Vec::new());
         }
+
+        tracing::info!("Found {} pending melt(s) to check", melt_sagas.len());
+
+        let mut results = Vec::new();
+
+        for saga in melt_sagas {
+            match self.resume_melt_saga(&saga).await {
+                Ok(Some(melted)) => {
+                    tracing::info!("Melt {} finalized with state {:?}", saga.id, melted.state());
+                    results.push(melted);
+                }
+                Ok(None) => {
+                    tracing::debug!("Melt {} still pending or compensated early", saga.id);
+                }
+                Err(e) => {
+                    tracing::error!("Failed to finalize melt {}: {}", saga.id, e);
+                    // Continue with other sagas instead of failing entirely
+                }
+            }
+        }
+
+        Ok(results)
+    }
+
+    /// Confirm a prepared melt with already-reserved proofs.
+    ///
+    /// This is used by `MultiMintPreparedMelt::confirm` which holds an `Arc<Wallet>`
+    /// and has already prepared/reserved proofs. For the normal API path, use
+    /// `PreparedMelt::confirm()` which uses the typestate saga.
+    ///
+    /// The `operation_id` and `quote` must correspond to an existing prepared saga.
+    #[instrument(skip(self, proofs, proofs_to_swap, metadata))]
+    #[allow(clippy::too_many_arguments)]
+    pub async fn confirm_prepared_melt(
+        &self,
+        operation_id: Uuid,
+        quote: MeltQuote,
+        proofs: Proofs,
+        proofs_to_swap: Proofs,
+        input_fee: Amount,
+        input_fee_without_swap: Amount,
+        metadata: HashMap<String, String>,
+    ) -> Result<FinalizedMelt, Error> {
+        self.confirm_prepared_melt_with_options(
+            operation_id,
+            quote,
+            proofs,
+            proofs_to_swap,
+            input_fee,
+            input_fee_without_swap,
+            metadata,
+            MeltConfirmOptions::default(),
+        )
+        .await
+    }
+
+    /// Confirm a prepared melt with already-reserved proofs and custom options.
+    ///
+    /// This is used by `MultiMintPreparedMelt::confirm_with_options` which holds an `Arc<Wallet>`
+    /// and has already prepared/reserved proofs.
+    #[instrument(skip(self, proofs, proofs_to_swap, metadata, options))]
+    #[allow(clippy::too_many_arguments)]
+    pub async fn confirm_prepared_melt_with_options(
+        &self,
+        operation_id: Uuid,
+        quote: MeltQuote,
+        proofs: Proofs,
+        proofs_to_swap: Proofs,
+        input_fee: Amount,
+        input_fee_without_swap: Amount,
+        metadata: HashMap<String, String>,
+        options: MeltConfirmOptions,
+    ) -> Result<FinalizedMelt, Error> {
+        // Fetch saga from DB for optimistic locking
+        let db_saga = self
+            .localstore
+            .get_saga(&operation_id)
+            .await?
+            .ok_or(Error::Custom("Saga not found".to_string()))?;
+
+        let saga = MeltSaga::from_prepared(
+            self,
+            operation_id,
+            quote,
+            proofs,
+            proofs_to_swap,
+            input_fee,
+            input_fee_without_swap,
+            db_saga,
+        );
+
+        let melt_requested = saga.request_melt_with_options(options).await?;
+        let finalized = melt_requested.execute(metadata).await?;
+
+        Ok(FinalizedMelt::new(
+            finalized.quote_id().to_string(),
+            finalized.state(),
+            finalized.payment_proof().map(|s| s.to_string()),
+            finalized.amount(),
+            finalized.fee_paid(),
+            finalized.into_change(),
+        ))
+    }
+
+    /// Cancel a prepared melt and release reserved proofs.
+    ///
+    /// This is used by `MultiMintPreparedMelt::cancel` which holds an `Arc<Wallet>`.
+    #[instrument(skip(self, proofs, proofs_to_swap))]
+    pub async fn cancel_prepared_melt(
+        &self,
+        operation_id: Uuid,
+        proofs: Proofs,
+        proofs_to_swap: Proofs,
+    ) -> Result<(), Error> {
+        tracing::info!("Cancelling prepared melt for operation {}", operation_id);
+
+        let mut all_ys = proofs.ys()?;
+        all_ys.extend(proofs_to_swap.ys()?);
+
+        if !all_ys.is_empty() {
+            self.localstore
+                .update_proofs_state(all_ys, State::Unspent)
+                .await?;
+        }
+
+        if let Err(e) = self.localstore.release_melt_quote(&operation_id).await {
+            tracing::warn!(
+                "Failed to release melt quote for operation {}: {}",
+                operation_id,
+                e
+            );
+        }
+
+        if let Err(e) = self.localstore.delete_saga(&operation_id).await {
+            tracing::warn!(
+                "Failed to delete melt saga {}: {}. Will be cleaned up on recovery.",
+                operation_id,
+                e
+            );
+        }
+
         Ok(())
     }
 
@@ -54,29 +473,32 @@ impl Wallet {
     pub(crate) async fn add_transaction_for_pending_melt(
         &self,
         quote: &MeltQuote,
-        response: &MeltQuoteBolt11Response<String>,
+        new_state: MeltQuoteState,
+        amount: Amount,
+        change_amount: Option<Amount>,
+        payment_preimage: Option<String>,
     ) -> Result<(), Error> {
-        if quote.state != response.state {
+        if quote.state != new_state {
             tracing::info!(
                 "Quote melt {} state changed from {} to {}",
                 quote.id,
                 quote.state,
-                response.state
+                new_state
             );
-            if response.state == MeltQuoteState::Paid {
+            if new_state == MeltQuoteState::Paid {
                 let pending_proofs = self
                     .get_proofs_with(Some(vec![State::Pending]), None)
                     .await?;
                 let proofs_total = pending_proofs.total_amount().unwrap_or_default();
-                let change_total = response.change_amount().unwrap_or_default();
+                let change_total = change_amount.unwrap_or_default();
 
                 self.localstore
                     .add_transaction(Transaction {
                         mint_url: self.mint_url.clone(),
                         direction: TransactionDirection::Outgoing,
-                        amount: response.amount,
+                        amount,
                         fee: proofs_total
-                            .checked_sub(response.amount)
+                            .checked_sub(amount)
                             .and_then(|amt| amt.checked_sub(change_total))
                             .unwrap_or_default(),
                         unit: quote.unit.clone(),
@@ -86,8 +508,12 @@ impl Wallet {
                         metadata: HashMap::new(),
                         quote_id: Some(quote.id.clone()),
                         payment_request: Some(quote.request.clone()),
-                        payment_proof: response.payment_preimage.clone(),
+                        payment_proof: payment_preimage,
                         payment_method: Some(quote.payment_method.clone()),
+                        saga_id: quote
+                            .used_by_operation
+                            .as_ref()
+                            .and_then(|id| Uuid::parse_str(id).ok()),
                     })
                     .await?;
             }
@@ -155,33 +581,233 @@ impl Wallet {
             self.melt_lightning_address_quote(address, amount).await
         }
     }
-    /// Unified melt quote method for all payment methods
+    /// Melt quote for all payment methods
     ///
-    /// Routes to the appropriate handler based on the payment method.
-    /// For custom payment methods, you can pass extra JSON data that will be
-    /// forwarded to the payment processor.
-    ///
-    /// # Arguments
-    /// * `method` - Payment method to use (bolt11, bolt12, or custom)
-    /// * `request` - Payment request string (invoice, offer, or custom format)
-    /// * `options` - Optional melt options (MPP, amountless, etc.)
-    /// * `extra` - Optional extra payment-method-specific data as JSON (for custom methods)
-    pub async fn melt_quote_unified(
+    /// Accepts `Bolt11Invoice`, `Offer`, `String`, or `&str` for the request parameter.
+    #[instrument(skip(self, request, options, extra))]
+    pub async fn melt_quote<T, R>(
         &self,
-        method: PaymentMethod,
-        request: String,
+        method: T,
+        request: R,
         options: Option<MeltOptions>,
-        extra: Option<serde_json::Value>,
-    ) -> Result<MeltQuote, Error> {
+        extra: Option<String>,
+    ) -> Result<MeltQuote, Error>
+    where
+        T: Into<PaymentMethod> + std::fmt::Debug,
+        R: std::fmt::Display,
+    {
+        let method: PaymentMethod = method.into();
+        let request_str = request.to_string();
+
         match method {
-            PaymentMethod::Known(KnownMethod::Bolt11) => self.melt_quote(request, options).await,
+            PaymentMethod::Known(KnownMethod::Bolt11) => {
+                self.melt_bolt11_quote(request_str, options).await
+            }
             PaymentMethod::Known(KnownMethod::Bolt12) => {
-                self.melt_bolt12_quote(request, options).await
+                self.melt_bolt12_quote(request_str, options).await
             }
             PaymentMethod::Custom(custom_method) => {
-                self.melt_quote_custom(&custom_method, request, options, extra)
+                let extra_json =
+                    extra.map(|s| serde_json::from_str(&s).unwrap_or(serde_json::Value::Null));
+                self.melt_quote_custom(&custom_method, request_str, options, extra_json)
                     .await
             }
         }
     }
+
+    /// Update the state of a melt quote
+    pub(crate) async fn update_melt_quote_state(
+        &self,
+        quote: &mut MeltQuote,
+        new_state: MeltQuoteState,
+        amount: Amount,
+        change_amount: Option<Amount>,
+        payment_preimage: Option<String>,
+    ) -> Result<(), Error> {
+        if let Err(e) = self
+            .add_transaction_for_pending_melt(
+                quote,
+                new_state,
+                amount,
+                change_amount,
+                payment_preimage.clone(),
+            )
+            .await
+        {
+            tracing::error!("Failed to add transaction for pending melt: {}", e);
+        }
+
+        quote.state = new_state;
+
+        match self.localstore.add_melt_quote(quote.clone()).await {
+            Ok(_) => Ok(()),
+            Err(e) => {
+                if matches!(e, cdk_common::database::Error::ConcurrentUpdate) {
+                    tracing::debug!(
+                        "Concurrent update detected for melt quote {}, retrying",
+                        quote.id
+                    );
+                    let mut fresh_quote = self
+                        .localstore
+                        .get_melt_quote(&quote.id)
+                        .await?
+                        .ok_or(Error::UnknownQuote)?;
+
+                    fresh_quote.state = new_state;
+
+                    match self.localstore.add_melt_quote(fresh_quote.clone()).await {
+                        Ok(_) => (),
+                        Err(e) => {
+                            if matches!(e, cdk_common::database::Error::ConcurrentUpdate) {
+                                return Err(Error::ConcurrentUpdate);
+                            }
+                            return Err(Error::Database(e));
+                        }
+                    }
+
+                    *quote = fresh_quote;
+                    Ok(())
+                } else {
+                    Err(Error::Database(e))
+                }
+            }
+        }
+    }
+
+    /// Check melt quote status
+    #[instrument(skip(self, quote_id))]
+    pub async fn check_melt_quote_status(&self, quote_id: &str) -> Result<MeltQuote, Error> {
+        let mut quote = self
+            .localstore
+            .get_melt_quote(quote_id)
+            .await?
+            .ok_or(Error::UnknownQuote)?;
+
+        // Check if there's an in-progress saga for this quote
+        if let Some(ref operation_id_str) = quote.used_by_operation {
+            if let Ok(operation_id) = uuid::Uuid::parse_str(operation_id_str) {
+                match self.localstore.get_saga(&operation_id).await {
+                    Ok(Some(saga)) => {
+                        // Saga exists - try to complete it
+                        tracing::info!(
+                            "Melt quote {} has in-progress saga {}, attempting to complete",
+                            quote_id,
+                            operation_id
+                        );
+
+                        match self.resume_melt_saga(&saga).await? {
+                            Some(_) => {
+                                // Saga completed - re-fetch quote from DB
+                                quote = self
+                                    .localstore
+                                    .get_melt_quote(quote_id)
+                                    .await?
+                                    .ok_or(Error::UnknownQuote)?;
+                            }
+                            None => {
+                                // Saga still pending (payment in progress or mint unreachable)
+                                // Return current quote state - no need to query mint again
+                                // since resume_melt_saga already checked
+                                return Ok(quote);
+                            }
+                        }
+                    }
+                    Ok(None) => {
+                        // Orphaned reservation - release it
+                        tracing::warn!(
+                            "Melt quote {} has orphaned reservation for operation {}, releasing",
+                            quote_id,
+                            operation_id
+                        );
+                        if let Err(e) = self.localstore.release_melt_quote(&operation_id).await {
+                            tracing::warn!("Failed to release orphaned melt quote: {}", e);
+                        }
+                    }
+                    Err(e) => {
+                        tracing::warn!("Failed to check saga for melt quote {}: {}", quote_id, e);
+                        return Err(Error::Database(e));
+                    }
+                }
+            }
+        }
+
+        match &quote.payment_method {
+            PaymentMethod::Known(KnownMethod::Bolt11) => {
+                let response = self.client.get_melt_quote_status(quote_id).await?;
+                self.update_melt_quote_state(
+                    &mut quote,
+                    response.state,
+                    response.amount,
+                    response.change_amount(),
+                    response.payment_preimage,
+                )
+                .await?;
+            }
+            PaymentMethod::Known(KnownMethod::Bolt12) => {
+                let response = self.client.get_melt_bolt12_quote_status(quote_id).await?;
+                self.update_melt_quote_state(
+                    &mut quote,
+                    response.state,
+                    response.amount,
+                    response.change_amount(),
+                    response.payment_preimage,
+                )
+                .await?;
+            }
+            PaymentMethod::Custom(method) => {
+                let response = self
+                    .client
+                    .get_melt_quote_custom_status(method, quote_id)
+                    .await?;
+                self.update_melt_quote_state(
+                    &mut quote,
+                    response.state,
+                    response.amount,
+                    response.change_amount(),
+                    response.payment_preimage,
+                )
+                .await?;
+            }
+        };
+
+        Ok(quote)
+    }
+    /// This returns the raw protocol response including change signatures,
+    /// which is needed by saga recovery flows. For normal status checking,
+    /// use `check_melt_quote_status()` instead.
+    ///
+    /// Routes to the correct client endpoint based on the payment method
+    /// stored in the quote.
+    #[instrument(skip(self, quote_id))]
+    pub(crate) async fn internal_check_melt_status(
+        &self,
+        quote_id: &str,
+    ) -> Result<MeltQuoteStatusResponse, Error> {
+        let quote = self
+            .localstore
+            .get_melt_quote(quote_id)
+            .await?
+            .ok_or(Error::UnknownQuote)?;
+
+        // Route to correct endpoint based on payment method
+        let response = match &quote.payment_method {
+            PaymentMethod::Known(KnownMethod::Bolt11) => {
+                let r = self.client.get_melt_quote_status(quote_id).await?;
+                MeltQuoteStatusResponse::Standard(r)
+            }
+            PaymentMethod::Known(KnownMethod::Bolt12) => {
+                let r = self.client.get_melt_bolt12_quote_status(quote_id).await?;
+                MeltQuoteStatusResponse::Standard(r)
+            }
+            PaymentMethod::Custom(method) => {
+                let r = self
+                    .client
+                    .get_melt_quote_custom_status(method, quote_id)
+                    .await?;
+                MeltQuoteStatusResponse::Custom(r)
+            }
+        };
+
+        Ok(response)
+    }
 }

+ 256 - 0
crates/cdk/src/wallet/melt/saga/compensation.rs

@@ -0,0 +1,256 @@
+//! Compensation actions for the melt saga.
+//!
+//! When a saga step fails, compensating actions are executed in reverse order (LIFO)
+//! to undo all completed steps and restore the database to its pre-saga state.
+
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use cdk_common::database::{self, WalletDatabase};
+use tracing::instrument;
+use uuid::Uuid;
+
+use crate::wallet::saga::CompensatingAction;
+// Re-export shared compensation actions used by melt saga
+pub(crate) use crate::wallet::saga::RevertProofReservation;
+use crate::Error;
+
+/// Compensation action to release a melt quote reservation.
+pub struct ReleaseMeltQuote {
+    /// Database reference
+    pub localstore: Arc<dyn WalletDatabase<database::Error> + Send + Sync>,
+    /// Operation ID that reserved the quote
+    pub operation_id: Uuid,
+}
+
+#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
+#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
+impl CompensatingAction for ReleaseMeltQuote {
+    #[instrument(skip_all)]
+    async fn execute(&self) -> Result<(), Error> {
+        tracing::info!(
+            "Compensation: Releasing melt quote reserved by operation {}",
+            self.operation_id
+        );
+
+        self.localstore
+            .release_melt_quote(&self.operation_id)
+            .await
+            .map_err(Error::Database)?;
+
+        Ok(())
+    }
+
+    fn name(&self) -> &'static str {
+        "ReleaseMeltQuote"
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use cdk_common::nut00::KnownMethod;
+    use cdk_common::nuts::{CurrencyUnit, MeltQuoteState, State};
+    use cdk_common::wallet::{
+        MeltQuote, OperationData, SwapOperationData, SwapSagaState, WalletSaga, WalletSagaState,
+    };
+    use cdk_common::{Amount, PaymentMethod};
+
+    use super::*;
+    use crate::wallet::saga::test_utils::*;
+    use crate::wallet::saga::CompensatingAction;
+
+    /// Create a test wallet saga for melt operations
+    fn test_melt_saga(mint_url: cdk_common::mint_url::MintUrl) -> WalletSaga {
+        WalletSaga::new(
+            uuid::Uuid::new_v4(),
+            WalletSagaState::Swap(SwapSagaState::ProofsReserved),
+            Amount::from(1000),
+            mint_url,
+            CurrencyUnit::Sat,
+            OperationData::Swap(SwapOperationData {
+                input_amount: Amount::from(1000),
+                output_amount: Amount::from(990),
+                counter_start: Some(0),
+                counter_end: Some(10),
+                blinded_messages: None,
+            }),
+        )
+    }
+
+    /// Create a test melt quote
+    fn test_melt_quote() -> MeltQuote {
+        MeltQuote {
+            id: format!("test_melt_quote_{}", uuid::Uuid::new_v4()),
+            unit: CurrencyUnit::Sat,
+            amount: Amount::from(1000),
+            request: "lnbc1000...".to_string(),
+            fee_reserve: Amount::from(10),
+            state: MeltQuoteState::Unpaid,
+            expiry: 9999999999,
+            payment_preimage: None,
+            payment_method: PaymentMethod::Known(KnownMethod::Bolt11),
+            used_by_operation: None,
+            version: 0,
+        }
+    }
+
+    // =========================================================================
+    // RevertProofReservation Tests
+    // =========================================================================
+
+    #[tokio::test]
+    async fn test_revert_proof_reservation_is_idempotent() {
+        let db = create_test_db().await;
+        let mint_url = test_mint_url();
+        let keyset_id = test_keyset_id();
+
+        // Create and store proof in Reserved state
+        let proof_info = test_proof_info(keyset_id, 100, mint_url.clone(), State::Reserved);
+        let proof_y = proof_info.y;
+        db.update_proofs(vec![proof_info], vec![]).await.unwrap();
+
+        let saga = test_melt_saga(mint_url);
+        let saga_id = saga.id;
+        db.add_saga(saga).await.unwrap();
+
+        let compensation = RevertProofReservation {
+            localstore: db.clone(),
+            proof_ys: vec![proof_y],
+            saga_id,
+        };
+
+        // Execute twice - should succeed both times
+        compensation.execute().await.unwrap();
+        compensation.execute().await.unwrap();
+
+        // Proof should still be Unspent
+        let proofs = db
+            .get_proofs(None, None, Some(vec![State::Unspent]), None)
+            .await
+            .unwrap();
+        assert_eq!(proofs.len(), 1);
+    }
+
+    #[tokio::test]
+    async fn test_revert_proof_reservation_handles_missing_saga() {
+        let db = create_test_db().await;
+        let mint_url = test_mint_url();
+        let keyset_id = test_keyset_id();
+
+        // Create and store proof
+        let proof_info = test_proof_info(keyset_id, 100, mint_url.clone(), State::Reserved);
+        let proof_y = proof_info.y;
+        db.update_proofs(vec![proof_info], vec![]).await.unwrap();
+
+        // Use a saga_id that doesn't exist
+        let saga_id = uuid::Uuid::new_v4();
+
+        let compensation = RevertProofReservation {
+            localstore: db.clone(),
+            proof_ys: vec![proof_y],
+            saga_id,
+        };
+
+        // Should succeed even though saga doesn't exist
+        compensation.execute().await.unwrap();
+
+        // Proof should be Unspent
+        let proofs = db
+            .get_proofs(None, None, Some(vec![State::Unspent]), None)
+            .await
+            .unwrap();
+        assert_eq!(proofs.len(), 1);
+    }
+
+    // =========================================================================
+    // ReleaseMeltQuote Tests
+    // =========================================================================
+
+    #[tokio::test]
+    async fn test_release_melt_quote_is_idempotent() {
+        let db = create_test_db().await;
+        let operation_id = uuid::Uuid::new_v4();
+
+        let mut quote = test_melt_quote();
+        quote.used_by_operation = Some(operation_id.to_string());
+        db.add_melt_quote(quote.clone()).await.unwrap();
+
+        let compensation = ReleaseMeltQuote {
+            localstore: db.clone(),
+            operation_id,
+        };
+
+        // Execute twice
+        compensation.execute().await.unwrap();
+        compensation.execute().await.unwrap();
+
+        let retrieved_quote = db.get_melt_quote(&quote.id).await.unwrap().unwrap();
+        assert!(retrieved_quote.used_by_operation.is_none());
+    }
+
+    #[tokio::test]
+    async fn test_release_melt_quote_handles_no_matching_quote() {
+        let db = create_test_db().await;
+        let operation_id = uuid::Uuid::new_v4();
+
+        // Don't add any quote - compensation should still succeed
+        let compensation = ReleaseMeltQuote {
+            localstore: db.clone(),
+            operation_id,
+        };
+
+        // Should not error even with no matching quote
+        let result = compensation.execute().await;
+        assert!(result.is_ok());
+    }
+
+    // =========================================================================
+    // Isolation Tests
+    // =========================================================================
+
+    #[tokio::test]
+    async fn test_compensation_only_affects_specified_proofs() {
+        let db = create_test_db().await;
+        let mint_url = test_mint_url();
+        let keyset_id = test_keyset_id();
+
+        // Create two proofs, both Reserved
+        let proof_info_1 = test_proof_info(keyset_id, 100, mint_url.clone(), State::Reserved);
+        let proof_info_2 = test_proof_info(keyset_id, 200, mint_url.clone(), State::Reserved);
+
+        let proof_y_1 = proof_info_1.y;
+        let proof_y_2 = proof_info_2.y;
+
+        db.update_proofs(vec![proof_info_1, proof_info_2], vec![])
+            .await
+            .unwrap();
+
+        let saga = test_melt_saga(mint_url);
+        let saga_id = saga.id;
+        db.add_saga(saga).await.unwrap();
+
+        // Only revert the first proof
+        let compensation = RevertProofReservation {
+            localstore: db.clone(),
+            proof_ys: vec![proof_y_1],
+            saga_id,
+        };
+        compensation.execute().await.unwrap();
+
+        // First proof should be Unspent
+        let unspent = db
+            .get_proofs(None, None, Some(vec![State::Unspent]), None)
+            .await
+            .unwrap();
+        assert_eq!(unspent.len(), 1);
+        assert_eq!(unspent[0].y, proof_y_1);
+
+        // Second proof should still be Reserved
+        let reserved = db
+            .get_proofs(None, None, Some(vec![State::Reserved]), None)
+            .await
+            .unwrap();
+        assert_eq!(reserved.len(), 1);
+        assert_eq!(reserved[0].y, proof_y_2);
+    }
+}

+ 1033 - 0
crates/cdk/src/wallet/melt/saga/mod.rs

@@ -0,0 +1,1033 @@
+//! Melt Saga - Type State Pattern Implementation
+//!
+//! This module implements the saga pattern for melt operations using the typestate
+//! pattern to enforce valid state transitions at compile-time.
+//!
+//! # State Flow
+//!
+//! ```text
+//! [saga created] ──► ProofsReserved ──► MeltRequested ──► PaymentPending ──► [completed]
+//!                         │                   │                 │
+//!                         │                   └─────────────────┤
+//!                         │                                     ├─ quote Paid ─────────► [completed]
+//!                         │                                     ├─ quote Unpaid/Failed ► [compensated]
+//!                         │                                     └─ quote Pending ──────► [skipped]
+//!                         │
+//!                         └─ recovery ────────────────────────────────────────────────► [compensated]
+//! ```
+//!
+//! # States
+//!
+//! | State | Description |
+//! |-------|-------------|
+//! | `ProofsReserved` | Proofs reserved and quote locked, ready to initiate payment |
+//! | `MeltRequested` | Melt request sent to mint, Lightning payment initiated |
+//! | `PaymentPending` | Lightning payment in progress, awaiting confirmation from network |
+//!
+//! # Recovery Outcomes
+//!
+//! | Outcome | Description |
+//! |---------|-------------|
+//! | `[completed]` | Payment succeeded, proofs spent, change (if any) claimed |
+//! | `[compensated]` | Payment failed/cancelled, proofs and quote released |
+//! | `[skipped]` | Payment still pending, will retry on next recovery |
+
+use std::collections::HashMap;
+
+use cdk_common::amount::SplitTarget;
+use cdk_common::dhke::construct_proofs;
+use cdk_common::wallet::{
+    MeltOperationData, MeltQuote, MeltSagaState, OperationData, ProofInfo, Transaction,
+    TransactionDirection, WalletSaga, WalletSagaState,
+};
+use cdk_common::MeltQuoteState;
+use tracing::instrument;
+
+use self::compensation::{ReleaseMeltQuote, RevertProofReservation};
+use self::state::{Finalized, Initial, MeltRequested, Prepared};
+use super::MeltConfirmOptions;
+use crate::nuts::nut00::ProofsMethods;
+use crate::nuts::{MeltRequest, PreMintSecrets, Proofs, State};
+use crate::util::unix_time;
+use crate::wallet::saga::{add_compensation, new_compensations, Compensations};
+use crate::{ensure_cdk, Amount, Error, Wallet};
+
+pub(crate) mod compensation;
+pub(crate) mod resume;
+pub(crate) mod state;
+
+/// Saga pattern implementation for melt operations.
+///
+/// Uses the typestate pattern to enforce valid state transitions at compile-time.
+/// Each state (Initial, Prepared, Confirmed) is a distinct type, and operations
+/// are only available on the appropriate type.
+pub(crate) struct MeltSaga<'a, S> {
+    /// Wallet reference
+    wallet: &'a Wallet,
+    /// Compensating actions in LIFO order (most recent first)
+    compensations: Compensations,
+    /// State-specific data
+    state_data: S,
+}
+
+impl<'a> MeltSaga<'a, Initial> {
+    /// Create a new melt saga in the Initial state.
+    pub fn new(wallet: &'a Wallet) -> Self {
+        let operation_id = uuid::Uuid::new_v4();
+
+        Self {
+            wallet,
+            compensations: new_compensations(),
+            state_data: Initial { operation_id },
+        }
+    }
+
+    /// Initialize melt operation (common steps for prepare methods)
+    async fn initialize_melt(&mut self, quote_id: &str) -> Result<MeltQuote, Error> {
+        let quote_info = self
+            .wallet
+            .localstore
+            .get_melt_quote(quote_id)
+            .await?
+            .ok_or(Error::UnknownQuote)?;
+
+        ensure_cdk!(
+            quote_info.expiry.gt(&unix_time()),
+            Error::ExpiredQuote(quote_info.expiry, unix_time())
+        );
+
+        // Reserve the quote to prevent concurrent operations from using it
+        self.wallet
+            .localstore
+            .reserve_melt_quote(quote_id, &self.state_data.operation_id)
+            .await?;
+
+        // Register compensation to release quote on failure
+        add_compensation(
+            &mut self.compensations,
+            Box::new(ReleaseMeltQuote {
+                localstore: self.wallet.localstore.clone(),
+                operation_id: self.state_data.operation_id,
+            }),
+        )
+        .await;
+
+        Ok(quote_info)
+    }
+
+    /// Prepare the melt operation by selecting and reserving proofs.
+    ///
+    /// Loads the quote, selects and reserves proofs for the required amount.
+    ///
+    /// # Compensation
+    ///
+    /// Registers a compensation action that will revert proof reservation
+    /// if later steps fail.
+    #[instrument(skip_all)]
+    pub async fn prepare(
+        mut self,
+        quote_id: &str,
+        _metadata: HashMap<String, String>,
+    ) -> Result<MeltSaga<'a, Prepared>, Error> {
+        tracing::info!(
+            "Preparing melt for quote {} with operation {}",
+            quote_id,
+            self.state_data.operation_id
+        );
+
+        let quote_info = self.initialize_melt(quote_id).await?;
+
+        let inputs_needed_amount = quote_info.amount + quote_info.fee_reserve;
+
+        let active_keyset_ids = self
+            .wallet
+            .get_mint_keysets()
+            .await?
+            .into_iter()
+            .map(|k| k.id)
+            .collect();
+        let keyset_fees_and_amounts = self.wallet.get_keyset_fees_and_amounts().await?;
+
+        let available_proofs = self.wallet.get_unspent_proofs().await?;
+
+        let exact_input_proofs = Wallet::select_proofs(
+            inputs_needed_amount,
+            available_proofs.clone(),
+            &active_keyset_ids,
+            &keyset_fees_and_amounts,
+            true,
+        )?;
+        let proofs_total = exact_input_proofs.total_amount()?;
+
+        if proofs_total == inputs_needed_amount {
+            let proof_ys = exact_input_proofs.ys()?;
+            let operation_id = self.state_data.operation_id;
+
+            self.wallet
+                .localstore
+                .update_proofs_state(proof_ys.clone(), State::Reserved)
+                .await?;
+
+            let saga = WalletSaga::new(
+                operation_id,
+                WalletSagaState::Melt(MeltSagaState::ProofsReserved),
+                quote_info.amount,
+                self.wallet.mint_url.clone(),
+                self.wallet.unit.clone(),
+                OperationData::Melt(MeltOperationData {
+                    quote_id: quote_id.to_string(),
+                    amount: quote_info.amount,
+                    fee_reserve: quote_info.fee_reserve,
+                    counter_start: None,
+                    counter_end: None,
+                    change_amount: None,
+                    change_blinded_messages: None,
+                }),
+            );
+
+            self.wallet.localstore.add_saga(saga.clone()).await?;
+
+            add_compensation(
+                &mut self.compensations,
+                Box::new(RevertProofReservation {
+                    localstore: self.wallet.localstore.clone(),
+                    proof_ys,
+                    saga_id: operation_id,
+                }),
+            )
+            .await;
+
+            let input_fee = self.wallet.get_proofs_fee(&exact_input_proofs).await?.total;
+
+            return Ok(MeltSaga {
+                wallet: self.wallet,
+                compensations: self.compensations,
+                state_data: Prepared {
+                    operation_id: self.state_data.operation_id,
+                    quote: quote_info,
+                    proofs: exact_input_proofs,
+                    proofs_to_swap: Proofs::new(),
+                    swap_fee: Amount::ZERO,
+                    input_fee,
+                    input_fee_without_swap: input_fee,
+                    saga,
+                },
+            });
+        }
+
+        let active_keyset_id = self.wallet.get_active_keyset().await?.id;
+        let fee_and_amounts = self
+            .wallet
+            .get_keyset_fees_and_amounts_by_id(active_keyset_id)
+            .await?;
+
+        let estimated_output_count = inputs_needed_amount.split(&fee_and_amounts)?.len();
+        let estimated_melt_fee = self
+            .wallet
+            .get_keyset_count_fee(&active_keyset_id, estimated_output_count as u64)
+            .await?;
+
+        let selection_amount = inputs_needed_amount + estimated_melt_fee;
+
+        let input_proofs = Wallet::select_proofs(
+            selection_amount,
+            available_proofs,
+            &active_keyset_ids,
+            &keyset_fees_and_amounts,
+            true,
+        )?;
+
+        let input_fee = estimated_melt_fee;
+
+        let proofs_to_send = Proofs::new();
+        let proofs_to_swap = input_proofs;
+        let swap_fee = self.wallet.get_proofs_fee(&proofs_to_swap).await?.total;
+
+        let proof_ys = proofs_to_swap.ys()?;
+        let operation_id = self.state_data.operation_id;
+
+        if !proof_ys.is_empty() {
+            self.wallet
+                .localstore
+                .update_proofs_state(proof_ys.clone(), State::Reserved)
+                .await?;
+        }
+
+        let saga = WalletSaga::new(
+            operation_id,
+            WalletSagaState::Melt(MeltSagaState::ProofsReserved),
+            quote_info.amount,
+            self.wallet.mint_url.clone(),
+            self.wallet.unit.clone(),
+            OperationData::Melt(MeltOperationData {
+                quote_id: quote_id.to_string(),
+                amount: quote_info.amount,
+                fee_reserve: quote_info.fee_reserve,
+                counter_start: None,
+                counter_end: None,
+                change_amount: None,
+                change_blinded_messages: None, // Will be set when melt is requested
+            }),
+        );
+
+        self.wallet.localstore.add_saga(saga.clone()).await?;
+
+        add_compensation(
+            &mut self.compensations,
+            Box::new(RevertProofReservation {
+                localstore: self.wallet.localstore.clone(),
+                proof_ys,
+                saga_id: operation_id,
+            }),
+        )
+        .await;
+
+        let input_fee_without_swap = swap_fee;
+
+        Ok(MeltSaga {
+            wallet: self.wallet,
+            compensations: self.compensations,
+            state_data: Prepared {
+                operation_id: self.state_data.operation_id,
+                quote: quote_info,
+                proofs: proofs_to_send,
+                proofs_to_swap,
+                swap_fee,
+                input_fee,
+                input_fee_without_swap,
+                saga,
+            },
+        })
+    }
+
+    /// Prepare the melt operation with specific proofs (no automatic selection).
+    ///
+    /// Uses the provided proofs directly without automatic proof selection.
+    /// The caller must ensure the proofs cover the quote amount plus fee reserve.
+    ///
+    /// # Compensation
+    ///
+    /// Registers a compensation action that will revert proof state
+    /// if later steps fail.
+    #[instrument(skip_all)]
+    pub async fn prepare_with_proofs(
+        mut self,
+        quote_id: &str,
+        proofs: Proofs,
+        _metadata: HashMap<String, String>,
+    ) -> Result<MeltSaga<'a, Prepared>, Error> {
+        tracing::info!(
+            "Preparing melt with specific proofs for quote {} with operation {}",
+            quote_id,
+            self.state_data.operation_id
+        );
+
+        let quote_info = self.initialize_melt(quote_id).await?;
+
+        let proofs_total = proofs.total_amount()?;
+        let inputs_needed = quote_info.amount + quote_info.fee_reserve;
+        if proofs_total < inputs_needed {
+            return Err(Error::InsufficientFunds);
+        }
+
+        let operation_id = self.state_data.operation_id;
+        let proof_ys = proofs.ys()?;
+
+        // Since proofs may be external (not in our database), add them first
+        // Set to Reserved state like the regular prepare() does
+        let proofs_info = proofs
+            .clone()
+            .into_iter()
+            .map(|p| {
+                ProofInfo::new(
+                    p,
+                    self.wallet.mint_url.clone(),
+                    State::Reserved,
+                    self.wallet.unit.clone(),
+                )
+            })
+            .collect::<Result<Vec<ProofInfo>, _>>()?;
+
+        self.wallet
+            .localstore
+            .update_proofs(proofs_info, vec![])
+            .await?;
+
+        let saga = WalletSaga::new(
+            operation_id,
+            WalletSagaState::Melt(MeltSagaState::ProofsReserved),
+            quote_info.amount,
+            self.wallet.mint_url.clone(),
+            self.wallet.unit.clone(),
+            OperationData::Melt(MeltOperationData {
+                quote_id: quote_id.to_string(),
+                amount: quote_info.amount,
+                fee_reserve: quote_info.fee_reserve,
+                counter_start: None,
+                counter_end: None,
+                change_amount: None,
+                change_blinded_messages: None,
+            }),
+        );
+
+        self.wallet.localstore.add_saga(saga.clone()).await?;
+
+        add_compensation(
+            &mut self.compensations,
+            Box::new(RevertProofReservation {
+                localstore: self.wallet.localstore.clone(),
+                proof_ys,
+                saga_id: operation_id,
+            }),
+        )
+        .await;
+
+        let input_fee = self.wallet.get_proofs_fee(&proofs).await?.total;
+
+        Ok(MeltSaga {
+            wallet: self.wallet,
+            compensations: self.compensations,
+            state_data: Prepared {
+                operation_id: self.state_data.operation_id,
+                quote: quote_info,
+                proofs,
+                proofs_to_swap: Proofs::new(),
+                swap_fee: Amount::ZERO,
+                input_fee,
+                input_fee_without_swap: input_fee,
+                saga,
+            },
+        })
+    }
+}
+
+impl<'a> MeltSaga<'a, Prepared> {
+    /// Create a new melt saga directly in the Prepared state.
+    ///
+    /// This constructor is used by `confirm_prepared_melt` to reconstruct
+    /// a saga from stored state when confirming an already-prepared melt.
+    ///
+    /// Note: This bypasses the normal `prepare()` flow and assumes the caller
+    /// has already properly reserved the proofs.
+    #[allow(clippy::too_many_arguments)]
+    pub fn from_prepared(
+        wallet: &'a Wallet,
+        operation_id: uuid::Uuid,
+        quote: MeltQuote,
+        proofs: Proofs,
+        proofs_to_swap: Proofs,
+        input_fee: Amount,
+        input_fee_without_swap: Amount,
+        saga: WalletSaga,
+    ) -> Self {
+        Self {
+            wallet,
+            compensations: new_compensations(),
+            state_data: Prepared {
+                operation_id,
+                quote,
+                proofs,
+                proofs_to_swap,
+                swap_fee: Amount::ZERO,
+                input_fee,
+                input_fee_without_swap,
+                saga,
+            },
+        }
+    }
+
+    /// Get the operation ID
+    pub fn operation_id(&self) -> uuid::Uuid {
+        self.state_data.operation_id
+    }
+
+    /// Get the quote
+    pub fn quote(&self) -> &MeltQuote {
+        &self.state_data.quote
+    }
+
+    /// Get the proofs that will be used
+    pub fn proofs(&self) -> &Proofs {
+        &self.state_data.proofs
+    }
+
+    /// Get the proofs that need to be swapped
+    pub fn proofs_to_swap(&self) -> &Proofs {
+        &self.state_data.proofs_to_swap
+    }
+
+    /// Get the swap fee
+    pub fn swap_fee(&self) -> Amount {
+        self.state_data.swap_fee
+    }
+
+    /// Get the input fee
+    pub fn input_fee(&self) -> Amount {
+        self.state_data.input_fee
+    }
+
+    /// Get the input fee if swap is skipped
+    pub fn input_fee_without_swap(&self) -> Amount {
+        self.state_data.input_fee_without_swap
+    }
+
+    /// Build the melt request with options and transition to MeltRequested state.
+    ///
+    /// Performs swap if needed, sets proofs to Pending, creates pre-mint secrets for change.
+    ///
+    /// # Options
+    ///
+    /// - `skip_swap`: If true, skips the pre-melt swap and sends proofs directly.
+    ///
+    /// # Compensation
+    ///
+    /// On failure, compensations revert proof states and release the quote.
+    #[instrument(skip_all)]
+    pub async fn request_melt_with_options(
+        mut self,
+        options: MeltConfirmOptions,
+    ) -> Result<MeltSaga<'a, MeltRequested>, Error> {
+        let operation_id = self.state_data.operation_id;
+        let quote_info = self.state_data.quote.clone();
+        let input_fee = self.state_data.input_fee;
+
+        tracing::info!(
+            "Building melt request for quote {} with operation {} (skip_swap: {})",
+            quote_info.id,
+            operation_id,
+            options.skip_swap
+        );
+
+        let active_keyset_id = self.wallet.fetch_active_keyset().await?.id;
+        let mut final_proofs = self.state_data.proofs.clone();
+
+        // Handle proofs_to_swap based on skip_swap option
+        if !self.state_data.proofs_to_swap.is_empty() {
+            if options.skip_swap {
+                // Skip swap: use proofs_to_swap directly
+                // The mint will return change from the melt
+                tracing::debug!(
+                    "Skipping swap, using {} proofs directly (total: {})",
+                    self.state_data.proofs_to_swap.len(),
+                    self.state_data.proofs_to_swap.total_amount()?,
+                );
+                final_proofs.extend(self.state_data.proofs_to_swap.clone());
+            } else {
+                // Current behavior: swap first to get optimal denominations
+                let target_swap_amount = quote_info.amount + quote_info.fee_reserve + input_fee;
+
+                tracing::debug!(
+                    "Swapping {} proofs (total: {}) for target amount {}",
+                    self.state_data.proofs_to_swap.len(),
+                    self.state_data.proofs_to_swap.total_amount()?,
+                    target_swap_amount
+                );
+
+                if let Some(swapped) = self
+                    .wallet
+                    .swap(
+                        Some(target_swap_amount),
+                        SplitTarget::None,
+                        self.state_data.proofs_to_swap.clone(),
+                        None,
+                        false,
+                    )
+                    .await?
+                {
+                    final_proofs.extend(swapped);
+                }
+            }
+        }
+
+        // Recalculate the actual input_fee based on final_proofs
+        let actual_input_fee = self.wallet.get_proofs_fee(&final_proofs).await?.total;
+        let inputs_needed_amount = quote_info.amount + quote_info.fee_reserve + actual_input_fee;
+
+        let proofs_total = final_proofs.total_amount()?;
+        if proofs_total < inputs_needed_amount {
+            // Insufficient funds - execute compensations
+            self.compensate().await;
+            return Err(Error::InsufficientFunds);
+        }
+
+        // Set proofs to Pending state before making melt request
+        let proofs_info = final_proofs
+            .clone()
+            .into_iter()
+            .map(|p| {
+                ProofInfo::new_with_operations(
+                    p,
+                    self.wallet.mint_url.clone(),
+                    State::Pending,
+                    self.wallet.unit.clone(),
+                    Some(operation_id),
+                    None,
+                )
+            })
+            .collect::<Result<Vec<ProofInfo>, _>>()?;
+
+        self.wallet
+            .localstore
+            .update_proofs(proofs_info, vec![])
+            .await?;
+
+        // Add compensation to revert the new proofs if the saga fails later
+        add_compensation(
+            &mut self.compensations,
+            Box::new(RevertProofReservation {
+                localstore: self.wallet.localstore.clone(),
+                proof_ys: final_proofs.ys()?,
+                saga_id: operation_id,
+            }),
+        )
+        .await;
+
+        // Calculate change accounting for input fees
+        let change_amount = proofs_total - quote_info.amount - actual_input_fee;
+
+        let premint_secrets = if change_amount <= Amount::ZERO {
+            PreMintSecrets::new(active_keyset_id)
+        } else {
+            let num_secrets =
+                ((u64::from(change_amount) as f64).log2().ceil() as u64).max(1) as u32;
+
+            let new_counter = self
+                .wallet
+                .localstore
+                .increment_keyset_counter(&active_keyset_id, num_secrets)
+                .await?;
+
+            let count = new_counter - num_secrets;
+
+            PreMintSecrets::from_seed_blank(
+                active_keyset_id,
+                count,
+                &self.wallet.seed,
+                change_amount,
+            )?
+        };
+
+        // Get counter range for recovery
+        let counter_end = self
+            .wallet
+            .localstore
+            .increment_keyset_counter(&active_keyset_id, 0)
+            .await?;
+        let counter_start = counter_end.saturating_sub(premint_secrets.secrets.len() as u32);
+
+        let change_blinded_messages = if change_amount > Amount::ZERO {
+            Some(premint_secrets.blinded_messages())
+        } else {
+            None
+        };
+
+        // Update saga state to MeltRequested BEFORE making the melt call
+        let mut saga = self.state_data.saga.clone();
+        saga.update_state(WalletSagaState::Melt(MeltSagaState::MeltRequested));
+        if let OperationData::Melt(ref mut data) = saga.data {
+            data.counter_start = Some(counter_start);
+            data.counter_end = Some(counter_end);
+            data.change_amount = if change_amount > Amount::ZERO {
+                Some(change_amount)
+            } else {
+                None
+            };
+            data.change_blinded_messages = change_blinded_messages.clone();
+        }
+
+        if !self.wallet.localstore.update_saga(saga.clone()).await? {
+            return Err(Error::ConcurrentUpdate);
+        }
+
+        Ok(MeltSaga {
+            wallet: self.wallet,
+            compensations: self.compensations,
+            state_data: MeltRequested {
+                operation_id,
+                quote: quote_info,
+                final_proofs,
+                premint_secrets,
+                saga,
+            },
+        })
+    }
+
+    /// Execute compensations and cancel the melt.
+    async fn compensate(self) {
+        // Move compensations out of self to iterate while owning self
+        let mut compensations = self.compensations;
+        while let Some(action) = compensations.pop_front() {
+            if let Err(e) = action.execute().await {
+                tracing::warn!("Compensation {} failed: {}", action.name(), e);
+            }
+        }
+    }
+
+    /// Cancel the prepared melt and release reserved proofs.
+    pub async fn cancel(self) -> Result<(), Error> {
+        self.compensate().await;
+        Ok(())
+    }
+}
+
+impl std::fmt::Debug for MeltSaga<'_, Prepared> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("MeltSaga<Prepared>")
+            .field("operation_id", &self.state_data.operation_id)
+            .field("quote_id", &self.state_data.quote.id)
+            .field("amount", &self.state_data.quote.amount)
+            .field(
+                "proofs",
+                &self
+                    .state_data
+                    .proofs
+                    .iter()
+                    .map(|p| p.amount)
+                    .collect::<Vec<_>>(),
+            )
+            .field(
+                "proofs_to_swap",
+                &self
+                    .state_data
+                    .proofs_to_swap
+                    .iter()
+                    .map(|p| p.amount)
+                    .collect::<Vec<_>>(),
+            )
+            .field("swap_fee", &self.state_data.swap_fee)
+            .field("input_fee", &self.state_data.input_fee)
+            .finish()
+    }
+}
+
+impl<'a> MeltSaga<'a, MeltRequested> {
+    /// Execute the melt request.
+    ///
+    /// Sends the melt request to the mint and handles the response.
+    ///
+    /// # Returns
+    ///
+    /// - `Ok(MeltSaga<Finalized>)` on successful payment
+    /// - `Err(Error::PaymentPending)` when payment is in flight
+    /// - `Err(Error::PaymentFailed)` when payment fails
+    #[instrument(skip_all)]
+    pub async fn execute(
+        self,
+        metadata: HashMap<String, String>,
+    ) -> Result<MeltSaga<'a, Finalized>, Error> {
+        let operation_id = self.state_data.operation_id;
+        let quote_info = &self.state_data.quote;
+
+        tracing::info!(
+            "Executing melt request for quote {} with operation {}",
+            quote_info.id,
+            operation_id
+        );
+
+        let request = MeltRequest::new(
+            quote_info.id.clone(),
+            self.state_data.final_proofs.clone(),
+            Some(self.state_data.premint_secrets.blinded_messages()),
+        );
+
+        let melt_result = self
+            .wallet
+            .client
+            .post_melt(&quote_info.payment_method, request)
+            .await;
+
+        let melt_response = match melt_result {
+            Ok(response) => response,
+            Err(e) => {
+                return self.handle_melt_error(e, metadata).await;
+            }
+        };
+
+        match melt_response.state {
+            MeltQuoteState::Paid => self.finalize_success(melt_response, metadata).await,
+            MeltQuoteState::Pending => {
+                self.handle_pending().await;
+                Err(Error::PaymentPending)
+            }
+            MeltQuoteState::Failed => {
+                self.handle_failure().await;
+                Err(Error::PaymentFailed)
+            }
+            _ => {
+                tracing::warn!(
+                    "Melt quote {} returned unexpected state {:?}",
+                    quote_info.id,
+                    melt_response.state
+                );
+                self.finalize_success(melt_response, metadata).await
+            }
+        }
+    }
+
+    /// Handle a successful melt response.
+    async fn finalize_success(
+        self,
+        response: cdk_common::MeltQuoteBolt11Response<String>,
+        metadata: HashMap<String, String>,
+    ) -> Result<MeltSaga<'a, Finalized>, Error> {
+        let operation_id = self.state_data.operation_id;
+        let quote_info = self.state_data.quote.clone();
+        let final_proofs = &self.state_data.final_proofs;
+        let premint_secrets = &self.state_data.premint_secrets;
+
+        let active_keyset_id = self.wallet.fetch_active_keyset().await?.id;
+        let active_keys = self.wallet.load_keyset_keys(active_keyset_id).await?;
+
+        let change_proofs = match response.change {
+            Some(change) => {
+                let num_change_proof = change.len();
+
+                let num_change_proof = match (
+                    premint_secrets.len() < num_change_proof,
+                    premint_secrets.secrets().len() < num_change_proof,
+                ) {
+                    (true, _) | (_, true) => {
+                        tracing::error!("Mismatch in change promises to change");
+                        premint_secrets.len()
+                    }
+                    _ => num_change_proof,
+                };
+
+                Some(construct_proofs(
+                    change,
+                    premint_secrets.rs()[..num_change_proof].to_vec(),
+                    premint_secrets.secrets()[..num_change_proof].to_vec(),
+                    &active_keys,
+                )?)
+            }
+            None => None,
+        };
+
+        let payment_preimage = response.payment_preimage.clone();
+
+        let proofs_total = final_proofs.total_amount()?;
+        let change_total = change_proofs
+            .as_ref()
+            .map(|p| p.total_amount())
+            .transpose()?
+            .unwrap_or(Amount::ZERO);
+        let fee = proofs_total - quote_info.amount - change_total;
+
+        let mut updated_quote = quote_info.clone();
+        updated_quote.state = response.state;
+        self.wallet.localstore.add_melt_quote(updated_quote).await?;
+
+        let change_proof_infos = match change_proofs.clone() {
+            Some(change_proofs) => change_proofs
+                .into_iter()
+                .map(|proof| {
+                    ProofInfo::new(
+                        proof,
+                        self.wallet.mint_url.clone(),
+                        State::Unspent,
+                        quote_info.unit.clone(),
+                    )
+                })
+                .collect::<Result<Vec<ProofInfo>, _>>()?,
+            None => Vec::new(),
+        };
+
+        let deleted_ys = final_proofs.ys()?;
+
+        self.wallet
+            .localstore
+            .update_proofs(change_proof_infos, deleted_ys)
+            .await?;
+
+        self.wallet
+            .localstore
+            .add_transaction(Transaction {
+                mint_url: self.wallet.mint_url.clone(),
+                direction: TransactionDirection::Outgoing,
+                amount: quote_info.amount,
+                fee,
+                unit: self.wallet.unit.clone(),
+                ys: final_proofs.ys()?,
+                timestamp: unix_time(),
+                memo: None,
+                metadata,
+                quote_id: Some(quote_info.id.clone()),
+                payment_request: Some(quote_info.request.clone()),
+                payment_proof: payment_preimage.clone(),
+                payment_method: Some(quote_info.payment_method.clone()),
+                saga_id: Some(operation_id),
+            })
+            .await?;
+
+        if let Err(e) = self
+            .wallet
+            .localstore
+            .release_melt_quote(&operation_id)
+            .await
+        {
+            tracing::warn!(
+                "Failed to release melt quote for operation {}: {}",
+                operation_id,
+                e
+            );
+        }
+
+        if let Err(e) = self.wallet.localstore.delete_saga(&operation_id).await {
+            tracing::warn!(
+                "Failed to delete melt saga {}: {}. Will be cleaned up on recovery.",
+                operation_id,
+                e
+            );
+        }
+
+        Ok(MeltSaga {
+            wallet: self.wallet,
+            compensations: self.compensations,
+            state_data: Finalized {
+                quote_id: quote_info.id.clone(),
+                state: response.state,
+                amount: quote_info.amount,
+                fee,
+                payment_proof: payment_preimage,
+                change: change_proofs,
+            },
+        })
+    }
+
+    /// Handle melt error by checking quote status.
+    async fn handle_melt_error(
+        self,
+        error: Error,
+        metadata: HashMap<String, String>,
+    ) -> Result<MeltSaga<'a, Finalized>, Error> {
+        let quote_info = &self.state_data.quote;
+
+        // Check for known terminal errors first
+        if matches!(error, Error::RequestAlreadyPaid) {
+            tracing::info!("Invoice already paid by another wallet - releasing proofs");
+            self.handle_failure().await;
+            return Err(error);
+        }
+
+        // On HTTP error, check quote status to determine if payment failed
+        tracing::warn!(
+            "Melt request failed with error: {}. Checking quote status...",
+            error
+        );
+
+        match self.wallet.internal_check_melt_status(&quote_info.id).await {
+            Ok(response) => match response.state() {
+                MeltQuoteState::Failed | MeltQuoteState::Unknown | MeltQuoteState::Unpaid => {
+                    tracing::info!(
+                        "Quote {} status is {:?} - releasing proofs",
+                        quote_info.id,
+                        response.state()
+                    );
+                    self.handle_failure().await;
+                    Err(Error::PaymentFailed)
+                }
+                MeltQuoteState::Paid => {
+                    tracing::info!(
+                        "Quote {} confirmed paid - finalizing with change",
+                        quote_info.id
+                    );
+                    // Convert to standard response for finalize_success
+                    // Custom payment methods will error here (not supported in current saga)
+                    let standard_response = response.into_standard()?;
+                    self.finalize_success(standard_response, metadata).await
+                }
+                MeltQuoteState::Pending => {
+                    tracing::info!(
+                        "Quote {} status is Pending - keeping proofs pending",
+                        quote_info.id
+                    );
+                    self.handle_pending().await;
+                    Err(Error::PaymentPending)
+                }
+            },
+            Err(check_err) => {
+                tracing::warn!(
+                    "Failed to check quote {} status: {}. Keeping proofs pending.",
+                    quote_info.id,
+                    check_err
+                );
+                self.handle_pending().await;
+                Err(Error::PaymentPending)
+            }
+        }
+    }
+
+    /// Handle pending payment state.
+    async fn handle_pending(&self) {
+        let operation_id = self.state_data.operation_id;
+        let quote_info = &self.state_data.quote;
+
+        tracing::info!(
+            "Melt quote {} is pending - proofs kept in pending state",
+            quote_info.id
+        );
+
+        let mut pending_saga = self.state_data.saga.clone();
+        pending_saga.update_state(WalletSagaState::Melt(MeltSagaState::PaymentPending));
+
+        if let Err(e) = self.wallet.localstore.update_saga(pending_saga).await {
+            tracing::warn!(
+                "Failed to update saga {} to PaymentPending state: {}",
+                operation_id,
+                e
+            );
+        }
+    }
+
+    /// Handle failed payment - release proofs and clean up.
+    async fn handle_failure(&self) {
+        let operation_id = self.state_data.operation_id;
+        let final_proofs = &self.state_data.final_proofs;
+
+        if let Ok(all_ys) = final_proofs.ys() {
+            let _ = self
+                .wallet
+                .localstore
+                .update_proofs_state(all_ys, State::Unspent)
+                .await;
+        }
+        let _ = self
+            .wallet
+            .localstore
+            .release_melt_quote(&operation_id)
+            .await;
+        let _ = self.wallet.localstore.delete_saga(&operation_id).await;
+    }
+}
+
+impl<'a> MeltSaga<'a, Finalized> {
+    /// Get the quote ID
+    pub fn quote_id(&self) -> &str {
+        &self.state_data.quote_id
+    }
+
+    /// Get the melt quote state
+    pub fn state(&self) -> MeltQuoteState {
+        self.state_data.state
+    }
+
+    /// Get the amount that was melted
+    pub fn amount(&self) -> Amount {
+        self.state_data.amount
+    }
+
+    /// Get the fee paid
+    pub fn fee_paid(&self) -> Amount {
+        self.state_data.fee
+    }
+
+    /// Get the payment proof (e.g., Lightning preimage)
+    pub fn payment_proof(&self) -> Option<&str> {
+        self.state_data.payment_proof.as_deref()
+    }
+
+    /// Consume the saga and return the change proofs
+    pub fn into_change(self) -> Option<Proofs> {
+        self.state_data.change
+    }
+}

+ 561 - 0
crates/cdk/src/wallet/melt/saga/resume.rs

@@ -0,0 +1,561 @@
+//! Resume logic for melt sagas after crash recovery.
+//!
+//! This module handles resuming incomplete melt sagas that were interrupted
+//! by a crash. It determines the payment status by querying the mint and
+//! either completes the operation or compensates.
+
+use cdk_common::wallet::{MeltOperationData, MeltSagaState, OperationData, WalletSaga};
+use cdk_common::{Amount, MeltQuoteState};
+use tracing::instrument;
+
+use crate::nuts::State;
+use crate::types::FinalizedMelt;
+use crate::wallet::melt::saga::compensation::ReleaseMeltQuote;
+use crate::wallet::melt::MeltQuoteStatusResponse;
+use crate::wallet::recovery::RecoveryHelpers;
+use crate::wallet::saga::{CompensatingAction, RevertProofReservation};
+use crate::{Error, Wallet};
+
+impl Wallet {
+    /// Resume an incomplete melt saga after crash recovery.
+    ///
+    /// Determines the payment status by querying the mint and either
+    /// completes the operation or compensates.
+    ///
+    /// # Returns
+    ///
+    /// - `Ok(Some(FinalizedMelt))` - The melt was finalized or compensated
+    /// - `Ok(None)` - The melt was skipped (still pending, mint unreachable)
+    /// - `Err(e)` - An error occurred during recovery
+    #[instrument(skip(self, saga))]
+    pub async fn resume_melt_saga(
+        &self,
+        saga: &WalletSaga,
+    ) -> Result<Option<FinalizedMelt>, Error> {
+        let state = match &saga.state {
+            cdk_common::wallet::WalletSagaState::Melt(s) => s,
+            _ => {
+                return Err(Error::Custom(format!(
+                    "Invalid saga state type for melt saga {}",
+                    saga.id
+                )))
+            }
+        };
+
+        let data = match &saga.data {
+            OperationData::Melt(d) => d,
+            _ => {
+                return Err(Error::Custom(format!(
+                    "Invalid operation data type for melt saga {}",
+                    saga.id
+                )))
+            }
+        };
+
+        match state {
+            MeltSagaState::ProofsReserved => {
+                // No melt was executed - safe to compensate
+                // Return FinalizedMelt with Unpaid state so caller counts it as compensated
+                tracing::info!(
+                    "Melt saga {} in ProofsReserved state - compensating",
+                    saga.id
+                );
+                self.compensate_melt(&saga.id).await?;
+                Ok(Some(FinalizedMelt::new(
+                    data.quote_id.clone(),
+                    MeltQuoteState::Unpaid,
+                    None,
+                    data.amount,
+                    Amount::ZERO,
+                    None,
+                )))
+            }
+            MeltSagaState::MeltRequested | MeltSagaState::PaymentPending => {
+                // Melt was requested or payment is pending - check quote state
+                tracing::info!(
+                    "Melt saga {} in {:?} state - checking quote state",
+                    saga.id,
+                    state
+                );
+                self.recover_or_compensate_melt(&saga.id, data).await
+            }
+        }
+    }
+
+    /// Check quote status and either complete melt or compensate.
+    ///
+    /// Returns `Some(FinalizedMelt)` for finalized melts (paid or failed),
+    /// `None` for still-pending melts that should be retried later.
+    async fn recover_or_compensate_melt(
+        &self,
+        saga_id: &uuid::Uuid,
+        data: &MeltOperationData,
+    ) -> Result<Option<FinalizedMelt>, Error> {
+        // Check quote state with the mint
+        match self.internal_check_melt_status(&data.quote_id).await {
+            Ok(quote_status) => match quote_status.state() {
+                MeltQuoteState::Paid => {
+                    // Payment succeeded - mark proofs as spent and recover change
+                    tracing::info!("Melt saga {} - payment succeeded, finalizing", saga_id);
+                    let melted = self
+                        .complete_melt_from_restore(saga_id, data, &quote_status)
+                        .await?;
+                    Ok(Some(melted))
+                }
+                MeltQuoteState::Unpaid | MeltQuoteState::Failed => {
+                    // Payment failed - compensate and return FinalizedMelt with failed state
+                    tracing::info!("Melt saga {} - payment failed, compensating", saga_id);
+                    self.compensate_melt(saga_id).await?;
+                    Ok(Some(FinalizedMelt::new(
+                        data.quote_id.clone(),
+                        quote_status.state(),
+                        None,
+                        data.amount,
+                        Amount::ZERO,
+                        None,
+                    )))
+                }
+                MeltQuoteState::Pending | MeltQuoteState::Unknown => {
+                    // Still pending or unknown - skip and retry later
+                    tracing::info!("Melt saga {} - payment pending/unknown, skipping", saga_id);
+                    Ok(None)
+                }
+            },
+            Err(e) => {
+                tracing::warn!(
+                    "Melt saga {} - can't check quote state ({}), skipping",
+                    saga_id,
+                    e
+                );
+                Ok(None)
+            }
+        }
+    }
+
+    /// Complete a melt by marking proofs as spent and restoring change.
+    async fn complete_melt_from_restore(
+        &self,
+        saga_id: &uuid::Uuid,
+        data: &MeltOperationData,
+        quote_status: &MeltQuoteStatusResponse,
+    ) -> Result<FinalizedMelt, Error> {
+        // Mark input proofs as spent
+        let reserved_proofs = self.localstore.get_reserved_proofs(saga_id).await?;
+        let input_amount =
+            Amount::try_sum(reserved_proofs.iter().map(|p| p.proof.amount)).unwrap_or(Amount::ZERO);
+
+        if !reserved_proofs.is_empty() {
+            let proof_ys: Vec<_> = reserved_proofs.iter().map(|p| p.y).collect();
+            self.localstore
+                .update_proofs_state(proof_ys, State::Spent)
+                .await?;
+        }
+
+        // Try to recover change proofs using stored blinded messages
+        let change_proofs = if let Some(ref change_blinded_messages) = data.change_blinded_messages
+        {
+            if !change_blinded_messages.is_empty() {
+                match self
+                    .restore_outputs(
+                        saga_id,
+                        "Melt",
+                        Some(change_blinded_messages.as_slice()),
+                        data.counter_start,
+                        data.counter_end,
+                    )
+                    .await
+                {
+                    Ok(Some(change_proof_infos)) => {
+                        let proofs: Vec<_> =
+                            change_proof_infos.iter().map(|p| p.proof.clone()).collect();
+                        self.localstore
+                            .update_proofs(change_proof_infos, vec![])
+                            .await?;
+                        Some(proofs)
+                    }
+                    Ok(None) => {
+                        tracing::warn!(
+                            "Melt saga {} - couldn't restore change proofs. \
+                             Run wallet.restore() to recover any missing change.",
+                            saga_id
+                        );
+                        None
+                    }
+                    Err(e) => {
+                        tracing::warn!(
+                            "Melt saga {} - failed to recover change: {}. \
+                             Run wallet.restore() to recover any missing change.",
+                            saga_id,
+                            e
+                        );
+                        None
+                    }
+                }
+            } else {
+                None
+            }
+        } else {
+            tracing::warn!(
+                "Melt saga {} - payment succeeded but no change blinded messages stored. \
+                 Run wallet.restore() to recover any missing change.",
+                saga_id
+            );
+            None
+        };
+
+        // Calculate fee paid
+        let change_amount = change_proofs
+            .as_ref()
+            .and_then(|p| Amount::try_sum(p.iter().map(|proof| proof.amount)).ok())
+            .unwrap_or(Amount::ZERO);
+        let fee_paid = input_amount
+            .checked_sub(data.amount + change_amount)
+            .unwrap_or(Amount::ZERO);
+
+        self.localstore.delete_saga(saga_id).await?;
+
+        Ok(FinalizedMelt::new(
+            data.quote_id.clone(),
+            MeltQuoteState::Paid,
+            quote_status.payment_preimage(),
+            data.amount,
+            fee_paid,
+            change_proofs,
+        ))
+    }
+
+    /// Compensate a melt saga by releasing proofs and the melt quote.
+    async fn compensate_melt(&self, saga_id: &uuid::Uuid) -> Result<(), Error> {
+        // Release melt quote (best-effort, continue on error)
+        if let Err(e) = (ReleaseMeltQuote {
+            localstore: self.localstore.clone(),
+            operation_id: *saga_id,
+        }
+        .execute()
+        .await)
+        {
+            tracing::warn!(
+                "Failed to release melt quote for saga {}: {}. Continuing with saga cleanup.",
+                saga_id,
+                e
+            );
+        }
+
+        // Release proofs and delete saga
+        let reserved_proofs = self.localstore.get_reserved_proofs(saga_id).await?;
+        let proof_ys = reserved_proofs.iter().map(|p| p.y).collect();
+
+        RevertProofReservation {
+            localstore: self.localstore.clone(),
+            proof_ys,
+            saga_id: *saga_id,
+        }
+        .execute()
+        .await
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::sync::Arc;
+
+    use cdk_common::nuts::{CurrencyUnit, State};
+    use cdk_common::wallet::{
+        MeltOperationData, MeltSagaState, OperationData, WalletSaga, WalletSagaState,
+    };
+    use cdk_common::{Amount, MeltQuoteBolt11Response, MeltQuoteState};
+
+    use crate::wallet::saga::test_utils::{
+        create_test_db, test_keyset_id, test_mint_url, test_proof_info,
+    };
+    use crate::wallet::test_utils::{
+        create_test_wallet_with_mock, test_melt_quote, MockMintConnector,
+    };
+
+    #[tokio::test]
+    async fn test_recover_melt_proofs_reserved() {
+        // Compensate: proofs released, quote released
+        let db = create_test_db().await;
+        let mint_url = test_mint_url();
+        let keyset_id = test_keyset_id();
+        let saga_id = uuid::Uuid::new_v4();
+        let quote_id = format!("test_melt_quote_{}", uuid::Uuid::new_v4());
+
+        // Create and reserve proofs
+        let proof_info = test_proof_info(keyset_id, 100, mint_url.clone(), State::Unspent);
+        let proof_y = proof_info.y;
+        db.update_proofs(vec![proof_info], vec![]).await.unwrap();
+        db.reserve_proofs(vec![proof_y], &saga_id).await.unwrap();
+
+        // Store melt quote before reserving it
+        let mut melt_quote = test_melt_quote();
+        melt_quote.id = quote_id.clone();
+        db.add_melt_quote(melt_quote).await.unwrap();
+        db.reserve_melt_quote(&quote_id, &saga_id).await.unwrap();
+
+        // Create saga in ProofsReserved state
+        let saga = WalletSaga::new(
+            saga_id,
+            WalletSagaState::Melt(MeltSagaState::ProofsReserved),
+            Amount::from(100),
+            mint_url.clone(),
+            CurrencyUnit::Sat,
+            OperationData::Melt(MeltOperationData {
+                quote_id,
+                amount: Amount::from(100),
+                fee_reserve: Amount::from(10),
+                counter_start: None,
+                counter_end: None,
+                change_amount: None,
+                change_blinded_messages: None,
+            }),
+        );
+        db.add_saga(saga).await.unwrap();
+
+        // Create wallet and recover
+        let mock_client = Arc::new(MockMintConnector::new());
+        let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await;
+        let result = wallet
+            .resume_melt_saga(&db.get_saga(&saga_id).await.unwrap().unwrap())
+            .await
+            .unwrap();
+
+        // Verify compensation
+        assert!(result.is_some());
+        let finalized = result.unwrap();
+        assert_eq!(finalized.state(), MeltQuoteState::Unpaid);
+
+        // Proofs should be back to Unspent
+        let proofs = db
+            .get_proofs(None, None, Some(vec![State::Unspent]), None)
+            .await
+            .unwrap();
+        assert_eq!(proofs.len(), 1);
+
+        // Saga should be deleted
+        assert!(db.get_saga(&saga_id).await.unwrap().is_none());
+    }
+
+    #[tokio::test]
+    async fn test_recover_melt_melt_requested_quote_paid() {
+        // Mock: quote Paid → complete melt, get change
+        let db = create_test_db().await;
+        let mint_url = test_mint_url();
+        let keyset_id = test_keyset_id();
+        let saga_id = uuid::Uuid::new_v4();
+        let quote_id = format!("test_melt_quote_{}", uuid::Uuid::new_v4());
+
+        // Create and reserve proofs
+        let proof_info = test_proof_info(keyset_id, 100, mint_url.clone(), State::Unspent);
+        let proof_y = proof_info.y;
+        db.update_proofs(vec![proof_info], vec![]).await.unwrap();
+        db.reserve_proofs(vec![proof_y], &saga_id).await.unwrap();
+
+        // Create saga in MeltRequested state
+        let saga = WalletSaga::new(
+            saga_id,
+            WalletSagaState::Melt(MeltSagaState::MeltRequested),
+            Amount::from(100),
+            mint_url.clone(),
+            CurrencyUnit::Sat,
+            OperationData::Melt(MeltOperationData {
+                quote_id: quote_id.clone(),
+                amount: Amount::from(100),
+                fee_reserve: Amount::from(10),
+                counter_start: None,
+                counter_end: None,
+                change_amount: None,
+                change_blinded_messages: None,
+            }),
+        );
+        db.add_saga(saga).await.unwrap();
+
+        // Store melt quote
+        let mut melt_quote = test_melt_quote();
+        melt_quote.id = quote_id.clone();
+        db.add_melt_quote(melt_quote).await.unwrap();
+
+        // Mock: quote is Paid
+        let mock_client = Arc::new(MockMintConnector::new());
+        mock_client.set_melt_quote_status_response(Ok(MeltQuoteBolt11Response {
+            quote: quote_id,
+            state: MeltQuoteState::Paid,
+            expiry: 9999999999,
+            fee_reserve: Amount::from(10),
+            amount: Amount::from(100),
+            request: Some("lnbc100...".to_string()),
+            payment_preimage: Some("preimage123".to_string()),
+            change: None,
+            unit: Some(CurrencyUnit::Sat),
+        }));
+
+        let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await;
+        let result = wallet
+            .resume_melt_saga(&db.get_saga(&saga_id).await.unwrap().unwrap())
+            .await
+            .unwrap();
+
+        // Verify melt completed
+        assert!(result.is_some());
+        let finalized = result.unwrap();
+        assert_eq!(finalized.state(), MeltQuoteState::Paid);
+
+        // Proofs should be marked spent
+        let proofs = db
+            .get_proofs(None, None, Some(vec![State::Spent]), None)
+            .await
+            .unwrap();
+        assert_eq!(proofs.len(), 1);
+
+        // Saga should be deleted
+        assert!(db.get_saga(&saga_id).await.unwrap().is_none());
+    }
+
+    #[tokio::test]
+    async fn test_recover_melt_melt_requested_quote_unpaid() {
+        // Mock: quote Unpaid → compensate
+        let db = create_test_db().await;
+        let mint_url = test_mint_url();
+        let keyset_id = test_keyset_id();
+        let saga_id = uuid::Uuid::new_v4();
+        let quote_id = format!("test_melt_quote_{}", uuid::Uuid::new_v4());
+
+        // Create and reserve proofs
+        let proof_info = test_proof_info(keyset_id, 100, mint_url.clone(), State::Unspent);
+        let proof_y = proof_info.y;
+        db.update_proofs(vec![proof_info], vec![]).await.unwrap();
+        db.reserve_proofs(vec![proof_y], &saga_id).await.unwrap();
+
+        // Create saga in MeltRequested state
+        let saga = WalletSaga::new(
+            saga_id,
+            WalletSagaState::Melt(MeltSagaState::MeltRequested),
+            Amount::from(100),
+            mint_url.clone(),
+            CurrencyUnit::Sat,
+            OperationData::Melt(MeltOperationData {
+                quote_id: quote_id.clone(),
+                amount: Amount::from(100),
+                fee_reserve: Amount::from(10),
+                counter_start: None,
+                counter_end: None,
+                change_amount: None,
+                change_blinded_messages: None,
+            }),
+        );
+        db.add_saga(saga).await.unwrap();
+
+        // Store melt quote
+        let mut melt_quote = test_melt_quote();
+        melt_quote.id = quote_id.clone();
+        db.add_melt_quote(melt_quote).await.unwrap();
+
+        // Mock: quote is Unpaid
+        let mock_client = Arc::new(MockMintConnector::new());
+        mock_client.set_melt_quote_status_response(Ok(MeltQuoteBolt11Response {
+            quote: quote_id,
+            state: MeltQuoteState::Unpaid,
+            expiry: 9999999999,
+            fee_reserve: Amount::from(10),
+            amount: Amount::from(100),
+            request: Some("lnbc100...".to_string()),
+            payment_preimage: None,
+            change: None,
+            unit: Some(CurrencyUnit::Sat),
+        }));
+
+        let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await;
+        let result = wallet
+            .resume_melt_saga(&db.get_saga(&saga_id).await.unwrap().unwrap())
+            .await
+            .unwrap();
+
+        // Verify compensation
+        assert!(result.is_some());
+        let finalized = result.unwrap();
+        assert!(
+            finalized.state() == MeltQuoteState::Unpaid
+                || finalized.state() == MeltQuoteState::Failed
+        );
+
+        // Proofs should be released
+        let proofs = db
+            .get_proofs(None, None, Some(vec![State::Unspent]), None)
+            .await
+            .unwrap();
+        assert_eq!(proofs.len(), 1);
+
+        // Saga should be deleted
+        assert!(db.get_saga(&saga_id).await.unwrap().is_none());
+    }
+
+    #[tokio::test]
+    async fn test_recover_melt_melt_requested_quote_pending() {
+        // Mock: quote Pending → skip
+        let db = create_test_db().await;
+        let mint_url = test_mint_url();
+        let keyset_id = test_keyset_id();
+        let saga_id = uuid::Uuid::new_v4();
+        let quote_id = format!("test_melt_quote_{}", uuid::Uuid::new_v4());
+
+        // Create and reserve proofs
+        let proof_info = test_proof_info(keyset_id, 100, mint_url.clone(), State::Unspent);
+        let proof_y = proof_info.y;
+        db.update_proofs(vec![proof_info], vec![]).await.unwrap();
+        db.reserve_proofs(vec![proof_y], &saga_id).await.unwrap();
+
+        // Create saga in MeltRequested state
+        let saga = WalletSaga::new(
+            saga_id,
+            WalletSagaState::Melt(MeltSagaState::MeltRequested),
+            Amount::from(100),
+            mint_url.clone(),
+            CurrencyUnit::Sat,
+            OperationData::Melt(MeltOperationData {
+                quote_id: quote_id.clone(),
+                amount: Amount::from(100),
+                fee_reserve: Amount::from(10),
+                counter_start: None,
+                counter_end: None,
+                change_amount: None,
+                change_blinded_messages: None,
+            }),
+        );
+        db.add_saga(saga).await.unwrap();
+
+        // Store melt quote
+        let mut melt_quote = test_melt_quote();
+        melt_quote.id = quote_id.clone();
+        db.add_melt_quote(melt_quote).await.unwrap();
+
+        // Mock: quote is Pending (no payment_preimage)
+        let mock_client = Arc::new(MockMintConnector::new());
+        mock_client.set_melt_quote_status_response(Ok(MeltQuoteBolt11Response {
+            quote: quote_id,
+            state: MeltQuoteState::Pending,
+            expiry: 9999999999,
+            fee_reserve: Amount::from(10),
+            amount: Amount::from(100),
+            request: Some("lnbc100...".to_string()),
+            payment_preimage: None,
+            change: None,
+            unit: Some(CurrencyUnit::Sat),
+        }));
+
+        let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await;
+        let result = wallet
+            .resume_melt_saga(&db.get_saga(&saga_id).await.unwrap().unwrap())
+            .await
+            .unwrap();
+
+        // Should skip (None returned for pending)
+        assert!(result.is_none());
+
+        // Proofs should still be reserved
+        let reserved = db.get_reserved_proofs(&saga_id).await.unwrap();
+        assert_eq!(reserved.len(), 1);
+
+        // Saga should still exist
+        assert!(db.get_saga(&saga_id).await.unwrap().is_some());
+    }
+}

+ 83 - 0
crates/cdk/src/wallet/melt/saga/state.rs

@@ -0,0 +1,83 @@
+//! State types for the Melt saga.
+//!
+//! Each state is a distinct type that holds the data relevant to that stage
+//! of the melt operation. The type state pattern ensures that only valid
+//! operations are available at each stage.
+//!
+//! # Type State Flow
+//!
+//! ```text
+//! Initial
+//!   └─> prepare() -> Prepared
+//!                      └─> request_melt_with_options() -> MeltRequested
+//!                                              └─> execute() -> Finalized
+//!                                                                 └─> amount(), fee(), change(), etc.
+//! ```
+//!
+//! Note: `PaymentPending` is a persistence state in `WalletSaga`, not a typestate.
+//! When payment is pending, the saga returns an error and recovery handles it later.
+
+use cdk_common::wallet::WalletSaga;
+use cdk_common::MeltQuoteState;
+use uuid::Uuid;
+
+use crate::nuts::{PreMintSecrets, Proofs};
+use crate::wallet::MeltQuote;
+use crate::Amount;
+
+/// Initial state - operation ID assigned but no work done yet.
+#[derive(Debug)]
+pub struct Initial {
+    /// Unique operation identifier for tracking and crash recovery
+    pub operation_id: Uuid,
+}
+
+/// Prepared state - proofs have been selected and reserved.
+pub struct Prepared {
+    /// Unique operation identifier
+    pub operation_id: Uuid,
+    /// The melt quote
+    pub quote: MeltQuote,
+    /// Proofs that will be used for the melt
+    pub proofs: Proofs,
+    /// Proofs that need to be swapped first (if any)
+    pub proofs_to_swap: Proofs,
+    /// Fee for the swap operation
+    pub swap_fee: Amount,
+    /// Input fee for the melt (after swap, on optimized proofs)
+    pub input_fee: Amount,
+    /// Input fee if swap is skipped (on all proofs directly)
+    pub input_fee_without_swap: Amount,
+    /// The persisted saga for optimistic locking
+    pub saga: WalletSaga,
+}
+
+/// MeltRequested state - melt request has been built and is ready to send.
+pub struct MeltRequested {
+    /// Unique operation identifier
+    pub operation_id: Uuid,
+    /// The melt quote
+    pub quote: MeltQuote,
+    /// Final proofs used for the melt (after any swaps)
+    pub final_proofs: Proofs,
+    /// Pre-mint secrets for change
+    pub premint_secrets: PreMintSecrets,
+    /// The persisted saga for optimistic locking (contains recovery data)
+    pub saga: WalletSaga,
+}
+
+/// Finalized state - melt completed successfully.
+pub struct Finalized {
+    /// Quote ID
+    pub quote_id: String,
+    /// The state of the melt quote (Paid)
+    pub state: MeltQuoteState,
+    /// Amount that was melted
+    pub amount: Amount,
+    /// Fee paid for the melt
+    pub fee: Amount,
+    /// Payment proof (e.g., Lightning preimage)
+    pub payment_proof: Option<String>,
+    /// Change proofs returned from the melt
+    pub change: Option<Proofs>,
+}

+ 6 - 7
crates/cdk/src/wallet/mod.rs

@@ -3,7 +3,6 @@
 use std::collections::HashMap;
 use std::fmt::Debug;
 use std::str::FromStr;
-use std::sync::atomic::AtomicBool;
 use std::sync::Arc;
 use std::time::Duration;
 
@@ -11,6 +10,7 @@ use cdk_common::amount::FeeAndAmounts;
 use cdk_common::database::{self, WalletDatabase};
 use cdk_common::parking_lot::RwLock;
 use cdk_common::subscription::WalletParams;
+use cdk_common::wallet::ProofInfo;
 use getrandom::getrandom;
 use subscription::{ActiveSubscription, SubscriptionManager};
 #[cfg(any(feature = "auth", feature = "npubcash"))]
@@ -29,7 +29,6 @@ use crate::nuts::{
     nut10, CurrencyUnit, Id, Keys, MintInfo, MintQuoteState, PreMintSecrets, Proofs,
     RestoreRequest, SpendingConditions, State,
 };
-use crate::types::ProofInfo;
 use crate::util::unix_time;
 use crate::wallet::mint_metadata_cache::MintMetadataCache;
 use crate::Amount;
@@ -56,11 +55,14 @@ pub mod payment_request;
 mod proofs;
 mod receive;
 mod reclaim;
+mod recovery;
+pub(crate) mod saga;
 mod send;
 #[cfg(not(target_arch = "wasm32"))]
 mod streams;
 pub mod subscription;
 mod swap;
+pub mod test_utils;
 mod transactions;
 pub mod util;
 
@@ -68,6 +70,7 @@ pub mod util;
 pub use auth::{AuthMintConnector, AuthWallet};
 pub use builder::WalletBuilder;
 pub use cdk_common::wallet as types;
+pub use melt::{MeltConfirmOptions, PreparedMelt};
 #[cfg(feature = "auth")]
 pub use mint_connector::http_client::AuthHttpClient as BaseAuthHttpClient;
 pub use mint_connector::http_client::HttpClient as BaseHttpClient;
@@ -82,6 +85,7 @@ pub use payment_request::CreateRequestParams;
 #[cfg(feature = "nostr")]
 pub use payment_request::NostrWaitInfo;
 pub use receive::ReceiveOptions;
+pub use recovery::RecoveryReport;
 pub use send::{PreparedSend, SendMemo, SendOptions};
 pub use types::{MeltQuote, MintQuote, SendKind};
 
@@ -112,7 +116,6 @@ pub struct Wallet {
     seed: [u8; 64],
     client: Arc<dyn MintConnector + Send + Sync>,
     subscription: SubscriptionManager,
-    in_error_swap_reverted_proofs: Arc<AtomicBool>,
 }
 
 const ALPHANUMERIC: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
@@ -298,12 +301,10 @@ impl Wallet {
     /// its URL
     #[instrument(skip(self))]
     pub async fn update_mint_url(&mut self, new_mint_url: MintUrl) -> Result<(), Error> {
-        // Update the mint URL in the wallet DB
         self.localstore
             .update_mint_url(self.mint_url.clone(), new_mint_url.clone())
             .await?;
 
-        // Update the mint URL in the wallet struct field
         self.mint_url = new_mint_url;
 
         Ok(())
@@ -615,8 +616,6 @@ impl Wallet {
                 start_counter += 100;
             }
 
-            // Set counter to highest found + 1 to avoid reusing any counter values
-            // that already have signatures at the mint
             if let Some(highest) = highest_counter {
                 self.localstore
                     .increment_keyset_counter(&keyset.id, highest + 1)

+ 615 - 66
crates/cdk/src/wallet/multi_mint_wallet.rs

@@ -15,18 +15,22 @@ use cdk_common::wallet::{MeltQuote, Transaction, TransactionDirection, Transacti
 use cdk_common::{database, KeySetInfo};
 use tokio::sync::RwLock;
 use tracing::instrument;
+use uuid::Uuid;
 use zeroize::Zeroize;
 
 use super::builder::WalletBuilder;
+use super::melt::MeltConfirmOptions;
 use super::receive::ReceiveOptions;
-use super::send::{PreparedSend, SendOptions};
+use super::send::{SendMemo, SendOptions};
 use super::{Error, Restored};
 use crate::amount::SplitTarget;
 use crate::mint_url::MintUrl;
 use crate::nuts::nut00::ProofsMethods;
 use crate::nuts::nut23::QuoteState;
-use crate::nuts::{CurrencyUnit, MeltOptions, Proof, Proofs, SpendingConditions, State, Token};
-use crate::types::Melted;
+use crate::nuts::{
+    CurrencyUnit, MeltOptions, PaymentMethod, Proof, Proofs, SpendingConditions, State, Token,
+};
+use crate::types::FinalizedMelt;
 #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
 use crate::wallet::mint_connector::transport::tor_transport::TorAsync;
 use crate::wallet::types::MintQuote;
@@ -148,6 +152,247 @@ impl WalletConfig {
     }
 }
 
+/// A prepared send operation from MultiMintWallet
+///
+/// This holds an `Arc<Wallet>` so it can call `.confirm()` without holding
+/// the RwLock. Created by [`MultiMintWallet::prepare_send`].
+pub struct MultiMintPreparedSend {
+    wallet: Arc<Wallet>,
+    operation_id: Uuid,
+    amount: Amount,
+    options: SendOptions,
+    proofs_to_swap: Proofs,
+    proofs_to_send: Proofs,
+    swap_fee: Amount,
+    send_fee: Amount,
+}
+
+impl MultiMintPreparedSend {
+    /// Operation ID for this prepared send
+    pub fn operation_id(&self) -> Uuid {
+        self.operation_id
+    }
+
+    /// Amount to send
+    pub fn amount(&self) -> Amount {
+        self.amount
+    }
+
+    /// Send options
+    pub fn options(&self) -> &SendOptions {
+        &self.options
+    }
+
+    /// Proofs that need to be swapped before sending
+    pub fn proofs_to_swap(&self) -> &Proofs {
+        &self.proofs_to_swap
+    }
+
+    /// Fee for the swap operation
+    pub fn swap_fee(&self) -> Amount {
+        self.swap_fee
+    }
+
+    /// Proofs that will be sent directly
+    pub fn proofs_to_send(&self) -> &Proofs {
+        &self.proofs_to_send
+    }
+
+    /// Fee the recipient will pay to redeem the token
+    pub fn send_fee(&self) -> Amount {
+        self.send_fee
+    }
+
+    /// All proofs (both to swap and to send)
+    pub fn proofs(&self) -> Proofs {
+        let mut proofs = self.proofs_to_swap.clone();
+        proofs.extend(self.proofs_to_send.clone());
+        proofs
+    }
+
+    /// Total fee (swap + send)
+    pub fn fee(&self) -> Amount {
+        self.swap_fee + self.send_fee
+    }
+
+    /// Confirm the prepared send and create a token
+    pub async fn confirm(self, memo: Option<SendMemo>) -> Result<Token, Error> {
+        self.wallet
+            .confirm_send(
+                self.operation_id,
+                self.amount,
+                self.options,
+                self.proofs_to_swap,
+                self.proofs_to_send,
+                self.swap_fee,
+                self.send_fee,
+                memo,
+            )
+            .await
+    }
+
+    /// Cancel the prepared send and release reserved proofs
+    pub async fn cancel(self) -> Result<(), Error> {
+        self.wallet
+            .cancel_send(self.operation_id, self.proofs_to_swap, self.proofs_to_send)
+            .await
+    }
+}
+
+impl std::fmt::Debug for MultiMintPreparedSend {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("MultiMintPreparedSend")
+            .field("operation_id", &self.operation_id)
+            .field("amount", &self.amount)
+            .field("swap_fee", &self.swap_fee)
+            .field("send_fee", &self.send_fee)
+            .finish()
+    }
+}
+
+/// A prepared melt operation from MultiMintWallet
+///
+/// This holds an `Arc<Wallet>` so it can call `.confirm()` without holding
+/// the RwLock. Created by [`MultiMintWallet::prepare_melt`].
+pub struct MultiMintPreparedMelt {
+    wallet: Arc<Wallet>,
+    operation_id: Uuid,
+    quote: MeltQuote,
+    proofs: Proofs,
+    proofs_to_swap: Proofs,
+    swap_fee: Amount,
+    input_fee: Amount,
+    input_fee_without_swap: Amount,
+    metadata: std::collections::HashMap<String, String>,
+}
+
+impl MultiMintPreparedMelt {
+    /// Get the operation ID
+    pub fn operation_id(&self) -> Uuid {
+        self.operation_id
+    }
+
+    /// Get the quote
+    pub fn quote(&self) -> &MeltQuote {
+        &self.quote
+    }
+
+    /// Get the amount to be melted
+    pub fn amount(&self) -> Amount {
+        self.quote.amount
+    }
+
+    /// Get the proofs that will be used
+    pub fn proofs(&self) -> &Proofs {
+        &self.proofs
+    }
+
+    /// Get the proofs that need to be swapped
+    pub fn proofs_to_swap(&self) -> &Proofs {
+        &self.proofs_to_swap
+    }
+
+    /// Get the swap fee
+    pub fn swap_fee(&self) -> Amount {
+        self.swap_fee
+    }
+
+    /// Get the input fee
+    pub fn input_fee(&self) -> Amount {
+        self.input_fee
+    }
+
+    /// Get the total fee (with swap, if applicable)
+    pub fn total_fee(&self) -> Amount {
+        self.swap_fee + self.input_fee
+    }
+
+    /// Returns true if a swap would be performed (proofs_to_swap is not empty)
+    pub fn requires_swap(&self) -> bool {
+        !self.proofs_to_swap.is_empty()
+    }
+
+    /// Get the total fee if swap is performed (current default behavior)
+    ///
+    /// This is swap_fee + input_fee on optimized proofs.
+    /// Same as [`total_fee()`](Self::total_fee).
+    pub fn total_fee_with_swap(&self) -> Amount {
+        self.swap_fee + self.input_fee
+    }
+
+    /// Get the input fee if swap is skipped (fee on all proofs sent directly)
+    pub fn input_fee_without_swap(&self) -> Amount {
+        self.input_fee_without_swap
+    }
+
+    /// Get the fee savings from skipping the swap
+    ///
+    /// Returns how much less you would pay in fees by using
+    /// `confirm_with_options(MeltConfirmOptions::skip_swap())`.
+    pub fn fee_savings_without_swap(&self) -> Amount {
+        self.total_fee_with_swap()
+            .checked_sub(self.input_fee_without_swap)
+            .unwrap_or(Amount::ZERO)
+    }
+
+    /// Get the expected change amount if swap is skipped
+    ///
+    /// This is how much would be "overpaid" and returned as change from the melt.
+    pub fn change_amount_without_swap(&self) -> Amount {
+        let all_proofs_total = self.proofs.total_amount().unwrap_or(Amount::ZERO)
+            + self.proofs_to_swap.total_amount().unwrap_or(Amount::ZERO);
+        let needed = self.quote.amount + self.quote.fee_reserve + self.input_fee_without_swap;
+        all_proofs_total.checked_sub(needed).unwrap_or(Amount::ZERO)
+    }
+
+    /// Confirm the prepared melt and execute the payment
+    pub async fn confirm(self) -> Result<FinalizedMelt, Error> {
+        self.confirm_with_options(MeltConfirmOptions::default())
+            .await
+    }
+
+    /// Confirm the prepared melt with custom options
+    ///
+    /// # Options
+    ///
+    /// - `skip_swap`: If true, skips the pre-melt swap and sends proofs directly.
+    pub async fn confirm_with_options(
+        self,
+        options: MeltConfirmOptions,
+    ) -> Result<FinalizedMelt, Error> {
+        self.wallet
+            .confirm_prepared_melt_with_options(
+                self.operation_id,
+                self.quote,
+                self.proofs,
+                self.proofs_to_swap,
+                self.input_fee,
+                self.input_fee_without_swap,
+                self.metadata,
+                options,
+            )
+            .await
+    }
+
+    /// Cancel the prepared melt and release reserved proofs
+    pub async fn cancel(self) -> Result<(), Error> {
+        self.wallet
+            .cancel_prepared_melt(self.operation_id, self.proofs, self.proofs_to_swap)
+            .await
+    }
+}
+
+impl std::fmt::Debug for MultiMintPreparedMelt {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("MultiMintPreparedMelt")
+            .field("operation_id", &self.operation_id)
+            .field("quote_id", &self.quote.id)
+            .field("amount", &self.quote.amount)
+            .field("total_fee", &self.total_fee())
+            .finish()
+    }
+}
+
 /// Multi Mint Wallet
 ///
 /// A wallet that manages multiple mints but supports only one currency unit.
@@ -185,12 +430,11 @@ impl WalletConfig {
 /// println!("Total balance: {} sats", balance);
 ///
 /// // Send tokens from a specific mint
-/// let prepared = wallet.prepare_send(
+/// let token = wallet.send(
 ///     mint_url1,
 ///     Amount::from(100),
 ///     Default::default()
 /// ).await?;
-/// let token = prepared.confirm(None).await?;
 /// # Ok(())
 /// # }
 /// ```
@@ -201,8 +445,8 @@ pub struct MultiMintWallet {
     seed: [u8; 64],
     /// The currency unit this wallet supports
     unit: CurrencyUnit,
-    /// Wallets indexed by mint URL
-    wallets: Arc<RwLock<BTreeMap<MintUrl, Wallet>>>,
+    /// Wallets indexed by mint URL (wrapped in Arc for sharing)
+    wallets: Arc<RwLock<BTreeMap<MintUrl, Arc<Wallet>>>>,
     /// Proxy configuration for HTTP clients (optional)
     proxy_config: Option<url::Url>,
     /// Shared Tor transport to be cloned into each TorHttpClient (if enabled)
@@ -314,9 +558,9 @@ impl MultiMintWallet {
             .create_wallet_with_config(mint_url.clone(), None)
             .await?;
 
-        // Insert into wallets map
+        // Insert into wallets map (wrapped in Arc)
         let mut wallets = self.wallets.write().await;
-        wallets.insert(mint_url, wallet);
+        wallets.insert(mint_url, Arc::new(wallet));
 
         Ok(())
     }
@@ -336,9 +580,9 @@ impl MultiMintWallet {
             .create_wallet_with_config(mint_url.clone(), Some(&config))
             .await?;
 
-        // Insert into wallets map
+        // Insert into wallets map (wrapped in Arc)
         let mut wallets = self.wallets.write().await;
-        wallets.insert(mint_url, wallet);
+        wallets.insert(mint_url, Arc::new(wallet));
 
         Ok(())
     }
@@ -357,7 +601,14 @@ impl MultiMintWallet {
         if self.has_mint(&mint_url).await {
             // Update existing wallet in place
             let mut wallets = self.wallets.write().await;
-            if let Some(wallet) = wallets.get_mut(&mint_url) {
+            if let Some(wallet_arc) = wallets.get_mut(&mint_url) {
+                // Try to get mutable access - fails if there are other Arc references
+                let wallet = Arc::get_mut(wallet_arc).ok_or_else(|| {
+                    Error::Custom(
+                        "Cannot modify wallet config while operations are in progress".to_string(),
+                    )
+                })?;
+
                 // Update target_proof_count if provided
                 if let Some(count) = config.target_proof_count {
                     wallet.set_target_proof_count(count);
@@ -414,6 +665,50 @@ impl MultiMintWallet {
         wallets.remove(mint_url);
     }
 
+    /// Update the mint URL for an existing wallet
+    ///
+    /// This updates the mint URL in the database and recreates the wallet with the new URL.
+    /// Returns an error if the old mint URL doesn't exist or if there are active operations
+    /// on the wallet.
+    #[instrument(skip(self))]
+    pub async fn update_mint_url(
+        &self,
+        old_mint_url: &MintUrl,
+        new_mint_url: MintUrl,
+    ) -> Result<(), Error> {
+        // Get write lock and check if wallet exists
+        let mut wallets = self.wallets.write().await;
+
+        // Remove old wallet - this will fail if there are other Arc references
+        let old_wallet_arc = wallets.remove(old_mint_url).ok_or(Error::UnknownMint {
+            mint_url: old_mint_url.to_string(),
+        })?;
+
+        // Check that we're the only holder of this Arc
+        // If not, someone else is using the wallet (e.g., PreparedSend)
+        let old_wallet = Arc::try_unwrap(old_wallet_arc).map_err(|_| {
+            Error::Custom("Cannot update mint URL while operations are in progress".to_string())
+        })?;
+
+        // Update the database
+        self.localstore
+            .update_mint_url(old_mint_url.clone(), new_mint_url.clone())
+            .await
+            .map_err(Error::Database)?;
+
+        // Create a new wallet with the new URL
+        // We drop the old wallet and create fresh to ensure clean state
+        drop(old_wallet);
+        let new_wallet = self
+            .create_wallet_with_config(new_mint_url.clone(), None)
+            .await?;
+
+        // Insert the new wallet
+        wallets.insert(new_mint_url, Arc::new(new_wallet));
+
+        Ok(())
+    }
+
     /// Internal: Create wallet with optional custom configuration
     ///
     /// Priority order for configuration:
@@ -587,13 +882,13 @@ impl MultiMintWallet {
 
     /// Get Wallets from MultiMintWallet
     #[instrument(skip(self))]
-    pub async fn get_wallets(&self) -> Vec<Wallet> {
+    pub async fn get_wallets(&self) -> Vec<Arc<Wallet>> {
         self.wallets.read().await.values().cloned().collect()
     }
 
     /// Get Wallet from MultiMintWallet
     #[instrument(skip(self))]
-    pub async fn get_wallet(&self, mint_url: &MintUrl) -> Option<Wallet> {
+    pub async fn get_wallet(&self, mint_url: &MintUrl) -> Option<Arc<Wallet>> {
         self.wallets.read().await.get(mint_url).cloned()
     }
 
@@ -799,18 +1094,67 @@ impl MultiMintWallet {
         Ok(total)
     }
 
-    /// Prepare to send tokens from a specific mint with optional transfer from other mints
+    /// Prepare a send operation from a specific mint
+    ///
+    /// Returns a [`MultiMintPreparedSend`] that holds an `Arc<Wallet>` and can be
+    /// confirmed later by calling `.confirm()`. This does not support automatic
+    /// transfers from other mints - use [`send`](Self::send) for that.
+    ///
+    /// # Example
+    /// ```ignore
+    /// let prepared = wallet.prepare_send(mint_url, amount, options).await?;
+    /// // Inspect the prepared send...
+    /// println!("Fee: {}", prepared.fee());
+    /// // Then confirm or cancel
+    /// let token = prepared.confirm(None).await?;
+    /// ```
+    #[instrument(skip(self))]
+    pub async fn prepare_send(
+        &self,
+        mint_url: MintUrl,
+        amount: Amount,
+        opts: SendOptions,
+    ) -> Result<MultiMintPreparedSend, Error> {
+        // Clone the Arc<Wallet> and release the lock immediately
+        let wallet = {
+            let wallets = self.wallets.read().await;
+            wallets
+                .get(&mint_url)
+                .ok_or(Error::UnknownMint {
+                    mint_url: mint_url.to_string(),
+                })?
+                .clone()
+        };
+
+        // Call prepare_send on the wallet (lock is released)
+        let prepared = wallet.prepare_send(amount, opts.clone()).await?;
+
+        // Extract data into MultiMintPreparedSend
+        // Clone the Arc again since `prepared` borrows from `wallet`
+        Ok(MultiMintPreparedSend {
+            wallet: Arc::clone(&wallet),
+            operation_id: prepared.operation_id(),
+            amount: prepared.amount(),
+            options: opts,
+            proofs_to_swap: prepared.proofs_to_swap().clone(),
+            proofs_to_send: prepared.proofs_to_send().clone(),
+            swap_fee: prepared.swap_fee(),
+            send_fee: prepared.send_fee(),
+        })
+    }
+
+    /// Send tokens from a specific mint with optional transfer from other mints
     ///
     /// This method ensures that sends always happen from only one mint. If the specified
     /// mint doesn't have sufficient balance and `allow_transfer` is enabled in options,
     /// it will first transfer funds from other mints to the target mint.
     #[instrument(skip(self))]
-    pub async fn prepare_send(
+    pub async fn send(
         &self,
         mint_url: MintUrl,
         amount: Amount,
         opts: MultiMintSendOptions,
-    ) -> Result<PreparedSend, Error> {
+    ) -> Result<Token, Error> {
         // Ensure the mint exists
         let wallets = self.wallets.read().await;
         let target_wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint {
@@ -820,9 +1164,12 @@ impl MultiMintWallet {
         // Check current balance of target mint
         let target_balance = target_wallet.total_balance().await?;
 
-        // If target mint has sufficient balance, prepare send directly
+        // If target mint has sufficient balance, send directly
         if target_balance >= amount {
-            return target_wallet.prepare_send(amount, opts.send_options).await;
+            let prepared = target_wallet
+                .prepare_send(amount, opts.send_options.clone())
+                .await?;
+            return prepared.confirm(opts.send_options.memo).await;
         }
 
         // If transfer is not allowed, return insufficient funds error
@@ -878,13 +1225,16 @@ impl MultiMintWallet {
         self.transfer_parallel(&mint_url, transfer_needed, source_mints)
             .await?;
 
-        // Now prepare the send from the target mint
+        // Now send from the target mint
         let wallets = self.wallets.read().await;
         let target_wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint {
             mint_url: mint_url.to_string(),
         })?;
 
-        target_wallet.prepare_send(amount, opts.send_options).await
+        let prepared = target_wallet
+            .prepare_send(amount, opts.send_options.clone())
+            .await?;
+        prepared.confirm(opts.send_options.memo).await
     }
 
     /// Transfer funds from a single source wallet to target mint using Lightning Network (melt/mint)
@@ -955,7 +1305,7 @@ impl MultiMintWallet {
         let target_balance_final = target_wallet.total_balance().await?;
 
         let amount_sent = source_balance_initial - source_balance_final;
-        let fees_paid = melted.fee_paid;
+        let fees_paid = melted.fee_paid();
 
         tracing::info!(
             "Transferred {} from {} to {} via Lightning (sent: {} sats, received: {} sats, fee: {} sats)",
@@ -985,11 +1335,18 @@ impl MultiMintWallet {
         source_balance: Amount,
     ) -> Result<(MintQuote, crate::wallet::types::MeltQuote), Error> {
         // Step 1: Create mint quote at target mint for the exact amount we want to receive
-        let mint_quote = target_wallet.mint_quote(amount, None).await?;
+        let mint_quote = target_wallet
+            .mint_quote(PaymentMethod::BOLT11, Some(amount), None, None)
+            .await?;
 
         // Step 2: Create melt quote at source mint for the invoice
         let melt_quote = source_wallet
-            .melt_quote(mint_quote.request.clone(), None)
+            .melt_quote(
+                PaymentMethod::BOLT11,
+                mint_quote.request.clone(),
+                None,
+                None,
+            )
             .await?;
 
         // Step 3: Check if source has enough balance for the total amount needed (amount + melt fees)
@@ -1014,9 +1371,16 @@ impl MultiMintWallet {
 
         // Step 1: Create melt quote for full balance to discover fees
         // We need to create a dummy mint quote first to get an invoice
-        let dummy_mint_quote = target_wallet.mint_quote(source_balance, None).await?;
+        let dummy_mint_quote = target_wallet
+            .mint_quote(PaymentMethod::BOLT11, Some(source_balance), None, None)
+            .await?;
         let probe_melt_quote = source_wallet
-            .melt_quote(dummy_mint_quote.request.clone(), None)
+            .melt_quote(
+                PaymentMethod::BOLT11,
+                dummy_mint_quote.request.clone(),
+                None,
+                None,
+            )
             .await?;
 
         // Step 2: Calculate actual receive amount (balance - fees)
@@ -1029,16 +1393,77 @@ impl MultiMintWallet {
         }
 
         // Step 3: Create final mint quote for the net amount
-        let final_mint_quote = target_wallet.mint_quote(receive_amount, None).await?;
+        let final_mint_quote = target_wallet
+            .mint_quote(PaymentMethod::BOLT11, Some(receive_amount), None, None)
+            .await?;
 
         // Step 4: Create final melt quote with the new invoice
         let final_melt_quote = source_wallet
-            .melt_quote(final_mint_quote.request.clone(), None)
+            .melt_quote(
+                PaymentMethod::BOLT11,
+                final_mint_quote.request.clone(),
+                None,
+                None,
+            )
             .await?;
 
         Ok((final_mint_quote, final_melt_quote))
     }
 
+    /// Get all pending send operations across all mints
+    ///
+    /// Returns a list of (MintUrl, Uuid) tuples for all pending sends.
+    #[instrument(skip(self))]
+    pub async fn get_pending_sends(&self) -> Result<Vec<(MintUrl, Uuid)>, Error> {
+        let mut pending_sends = Vec::new();
+
+        for (mint_url, wallet) in self.wallets.read().await.iter() {
+            let wallet_pending = wallet.get_pending_sends().await?;
+            for id in wallet_pending {
+                pending_sends.push((mint_url.clone(), id));
+            }
+        }
+
+        Ok(pending_sends)
+    }
+
+    /// Revoke a pending send operation for a specific mint
+    ///
+    /// Attempts to reclaim the funds by swapping the proofs back to the wallet.
+    /// If successful, the saga is deleted.
+    #[instrument(skip(self))]
+    pub async fn revoke_send(
+        &self,
+        mint_url: MintUrl,
+        operation_id: Uuid,
+    ) -> Result<Amount, Error> {
+        let wallets = self.wallets.read().await;
+        let wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint {
+            mint_url: mint_url.to_string(),
+        })?;
+
+        wallet.revoke_send(operation_id).await
+    }
+
+    /// Check status of a pending send operation for a specific mint
+    ///
+    /// Checks if the token has been claimed by the recipient.
+    /// If claimed, the saga is finalized (deleted).
+    /// Returns true if claimed, false if still pending.
+    #[instrument(skip(self))]
+    pub async fn check_send_status(
+        &self,
+        mint_url: MintUrl,
+        operation_id: Uuid,
+    ) -> Result<bool, Error> {
+        let wallets = self.wallets.read().await;
+        let wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint {
+            mint_url: mint_url.to_string(),
+        })?;
+
+        wallet.check_send_status(operation_id).await
+    }
+
     /// Execute the actual transfer using the prepared quotes
     async fn execute_transfer(
         &self,
@@ -1046,7 +1471,7 @@ impl MultiMintWallet {
         target_wallet: &Wallet,
         final_mint_quote: &MintQuote,
         final_melt_quote: &crate::wallet::types::MeltQuote,
-    ) -> Result<(Melted, Amount), Error> {
+    ) -> Result<(FinalizedMelt, Amount), Error> {
         // Step 1: Subscribe to mint quote updates before melting
         let mut subscription = target_wallet
             .subscribe(super::WalletSubscription::Bolt11MintQuoteState(vec![
@@ -1055,7 +1480,10 @@ impl MultiMintWallet {
             .await?;
 
         // Step 2: Melt from source wallet using the final melt quote
-        let melted = source_wallet.melt(&final_melt_quote.id).await?;
+        let prepared = source_wallet
+            .prepare_melt(&final_melt_quote.id, std::collections::HashMap::new())
+            .await?;
+        let melted = prepared.confirm().await?;
 
         // Step 3: Wait for payment confirmation via subscription
         tracing::debug!(
@@ -1201,12 +1629,16 @@ impl MultiMintWallet {
             mint_url: mint_url.to_string(),
         })?;
 
-        wallet.mint_quote(amount, description).await
+        wallet
+            .mint_quote(PaymentMethod::BOLT11, Some(amount), description, None)
+            .await
     }
 
-    /// Check a specific mint quote status
+    /// Refresh a specific mint quote status from the mint.
+    /// Updates local store with current state from mint.
+    /// Does NOT mint tokens - use wallet.mint() to mint a specific quote.
     #[instrument(skip(self))]
-    pub async fn check_mint_quote(
+    pub async fn refresh_mint_quote(
         &self,
         mint_url: &MintUrl,
         quote_id: &str,
@@ -1216,8 +1648,8 @@ impl MultiMintWallet {
             mint_url: mint_url.to_string(),
         })?;
 
-        // Check the quote state from the mint
-        wallet.mint_quote_state(quote_id).await?;
+        // Refresh the quote state from the mint
+        wallet.refresh_mint_quote_status(quote_id).await?;
 
         // Get the updated quote from local storage
         let quote = wallet
@@ -1249,10 +1681,38 @@ impl MultiMintWallet {
             .await
     }
 
-    /// Check all mint quotes
-    /// If quote is paid, wallet will mint
+    /// Refresh all unissued mint quote states
+    /// Does NOT mint - use mint_unissued_quotes() for that
     #[instrument(skip(self))]
-    pub async fn check_all_mint_quotes(&self, mint_url: Option<MintUrl>) -> Result<Amount, Error> {
+    pub async fn refresh_all_mint_quotes(
+        &self,
+        mint_url: Option<MintUrl>,
+    ) -> Result<Vec<MintQuote>, Error> {
+        let mut all_quotes = Vec::new();
+        match mint_url {
+            Some(mint_url) => {
+                let wallets = self.wallets.read().await;
+                let wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint {
+                    mint_url: mint_url.to_string(),
+                })?;
+
+                all_quotes = wallet.refresh_all_mint_quotes().await?;
+            }
+            None => {
+                for (_, wallet) in self.wallets.read().await.iter() {
+                    let quotes = wallet.refresh_all_mint_quotes().await?;
+                    all_quotes.extend(quotes);
+                }
+            }
+        }
+
+        Ok(all_quotes)
+    }
+
+    /// Refresh states and mint all unissued quotes
+    /// Returns total amount minted across all wallets
+    #[instrument(skip(self))]
+    pub async fn mint_unissued_quotes(&self, mint_url: Option<MintUrl>) -> Result<Amount, Error> {
         let mut total_amount = Amount::ZERO;
         match mint_url {
             Some(mint_url) => {
@@ -1261,11 +1721,11 @@ impl MultiMintWallet {
                     mint_url: mint_url.to_string(),
                 })?;
 
-                total_amount = wallet.check_all_mint_quotes().await?;
+                total_amount = wallet.mint_unissued_quotes().await?;
             }
             None => {
                 for (_, wallet) in self.wallets.read().await.iter() {
-                    let amount = wallet.check_all_mint_quotes().await?;
+                    let amount = wallet.mint_unissued_quotes().await?;
                     total_amount += amount;
                 }
             }
@@ -1558,7 +2018,12 @@ impl MultiMintWallet {
         let mut amount_received = Amount::ZERO;
 
         match wallet
-            .receive_proofs(proofs, opts.receive_options, token_data.memo().clone())
+            .receive_proofs(
+                proofs,
+                opts.receive_options,
+                token_data.memo().clone(),
+                Some(encoded_token.to_string()),
+            )
             .await
         {
             Ok(amount) => {
@@ -1677,22 +2142,34 @@ impl MultiMintWallet {
             mint_url: mint_url.to_string(),
         })?;
 
-        wallet.melt_quote(bolt11, options).await
+        wallet
+            .melt_quote(PaymentMethod::BOLT11, bolt11, options, None)
+            .await
     }
 
     /// Melt (pay invoice) from a specific mint using a quote ID
+    ///
+    /// For more control over fees, use `prepare_melt()` instead.
     #[instrument(skip(self))]
     pub async fn melt_with_mint(
         &self,
         mint_url: &MintUrl,
         quote_id: &str,
-    ) -> Result<Melted, Error> {
-        let wallets = self.wallets.read().await;
-        let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
-            mint_url: mint_url.to_string(),
-        })?;
+    ) -> Result<FinalizedMelt, Error> {
+        let wallet = {
+            let wallets = self.wallets.read().await;
+            wallets
+                .get(mint_url)
+                .ok_or(Error::UnknownMint {
+                    mint_url: mint_url.to_string(),
+                })?
+                .clone()
+        };
 
-        wallet.melt(quote_id).await
+        let prepared = wallet
+            .prepare_melt(quote_id, std::collections::HashMap::new())
+            .await?;
+        prepared.confirm().await
     }
 
     /// Melt specific proofs from a specific mint using a quote ID
@@ -1709,20 +2186,28 @@ impl MultiMintWallet {
     ///
     /// # Returns
     ///
-    /// A `Melted` result containing the payment details and any change proofs
+    /// A `FinalizedMelt` result containing the payment details and any change proofs
     #[instrument(skip(self, proofs))]
     pub async fn melt_proofs(
         &self,
         mint_url: &MintUrl,
         quote_id: &str,
         proofs: Proofs,
-    ) -> Result<Melted, Error> {
-        let wallets = self.wallets.read().await;
-        let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
-            mint_url: mint_url.to_string(),
-        })?;
+    ) -> Result<FinalizedMelt, Error> {
+        let wallet = {
+            let wallets = self.wallets.read().await;
+            wallets
+                .get(mint_url)
+                .ok_or(Error::UnknownMint {
+                    mint_url: mint_url.to_string(),
+                })?
+                .clone()
+        };
 
-        wallet.melt_proofs(quote_id, proofs).await
+        let prepared = wallet
+            .prepare_melt_proofs(quote_id, proofs, std::collections::HashMap::new())
+            .await?;
+        prepared.confirm().await
     }
 
     /// Check a specific melt quote status
@@ -1738,7 +2223,7 @@ impl MultiMintWallet {
         })?;
 
         // Check the quote state from the mint
-        wallet.melt_quote_status(quote_id).await?;
+        wallet.check_melt_quote_status(quote_id).await?;
 
         // Get the updated quote from local storage
         let quote = wallet
@@ -1783,7 +2268,9 @@ impl MultiMintWallet {
             let options = Some(MeltOptions::new_mpp(amount_msat));
 
             let task = spawn(async move {
-                let quote = wallet.melt_quote(bolt11_clone, options).await;
+                let quote = wallet
+                    .melt_quote(PaymentMethod::BOLT11, bolt11_clone, options, None)
+                    .await;
                 (mint_url_clone, quote)
             });
 
@@ -1815,7 +2302,7 @@ impl MultiMintWallet {
     pub async fn mpp_melt(
         &self,
         quotes: Vec<(MintUrl, String)>, // (mint_url, quote_id)
-    ) -> Result<Vec<(MintUrl, Melted)>, Error> {
+    ) -> Result<Vec<(MintUrl, FinalizedMelt)>, Error> {
         let mut results = Vec::new();
         let mut tasks = Vec::new();
 
@@ -1832,8 +2319,14 @@ impl MultiMintWallet {
             let mint_url_clone = mint_url.clone();
 
             let task = spawn(async move {
-                let melted = wallet.melt(&quote_id).await;
-                (mint_url_clone, melted)
+                let result = async {
+                    let prepared = wallet
+                        .prepare_melt(&quote_id, std::collections::HashMap::new())
+                        .await?;
+                    prepared.confirm().await
+                }
+                .await;
+                (mint_url_clone, result)
             });
 
             tasks.push(task);
@@ -1859,6 +2352,56 @@ impl MultiMintWallet {
         Ok(results)
     }
 
+    /// Prepare a melt operation from a specific mint
+    ///
+    /// Returns a [`MultiMintPreparedMelt`] that holds an `Arc<Wallet>` and can be
+    /// confirmed later by calling `.confirm()`.
+    ///
+    /// # Example
+    /// ```ignore
+    /// let quote = wallet.melt_quote(&mint_url, "lnbc...", None).await?;
+    /// let prepared = wallet.prepare_melt(&mint_url, &quote.id, HashMap::new()).await?;
+    /// // Inspect the prepared melt...
+    /// println!("Fee: {}", prepared.total_fee());
+    /// // Then confirm or cancel
+    /// let confirmed = prepared.confirm().await?;
+    /// ```
+    #[instrument(skip(self, metadata))]
+    pub async fn prepare_melt(
+        &self,
+        mint_url: &MintUrl,
+        quote_id: &str,
+        metadata: std::collections::HashMap<String, String>,
+    ) -> Result<MultiMintPreparedMelt, Error> {
+        // Clone the Arc<Wallet> and release the lock immediately
+        let wallet = {
+            let wallets = self.wallets.read().await;
+            wallets
+                .get(mint_url)
+                .ok_or(Error::UnknownMint {
+                    mint_url: mint_url.to_string(),
+                })?
+                .clone()
+        };
+
+        // Call prepare_melt on the wallet (lock is released)
+        let prepared = wallet.prepare_melt(quote_id, metadata.clone()).await?;
+
+        // Extract data into MultiMintPreparedMelt
+        // Clone the Arc again since `prepared` borrows from `wallet`
+        Ok(MultiMintPreparedMelt {
+            wallet: Arc::clone(&wallet),
+            operation_id: prepared.operation_id(),
+            quote: prepared.quote().clone(),
+            proofs: prepared.proofs().clone(),
+            proofs_to_swap: prepared.proofs_to_swap().clone(),
+            swap_fee: prepared.swap_fee(),
+            input_fee: prepared.input_fee(),
+            input_fee_without_swap: prepared.input_fee_without_swap(),
+            metadata,
+        })
+    }
+
     /// Melt (pay invoice) with automatic wallet selection (deprecated, use specific mint functions for better control)
     ///
     /// Automatically selects the best wallet to pay from based on:
@@ -1875,7 +2418,7 @@ impl MultiMintWallet {
     /// let invoice = "lnbc100n1p...";
     ///
     /// let result = wallet.melt(invoice, None, None).await?;
-    /// println!("Paid {} sats, fee was {} sats", result.amount, result.fee_paid);
+    /// println!("Paid {} sats, fee was {} sats", result.amount(), result.fee_paid());
     /// # Ok(())
     /// # }
     /// ```
@@ -1885,7 +2428,7 @@ impl MultiMintWallet {
         bolt11: &str,
         options: Option<MeltOptions>,
         max_fee: Option<Amount>,
-    ) -> Result<Melted, Error> {
+    ) -> Result<FinalizedMelt, Error> {
         // Parse the invoice to get the amount
         let invoice = bolt11
             .parse::<crate::Bolt11Invoice>()
@@ -1915,7 +2458,10 @@ impl MultiMintWallet {
         let mut best_wallet = None;
 
         for (_, wallet) in eligible_wallets.iter() {
-            match wallet.melt_quote(bolt11.to_string(), options).await {
+            match wallet
+                .melt_quote(PaymentMethod::BOLT11, bolt11.to_string(), options, None)
+                .await
+            {
                 Ok(quote) => {
                     if let Some(max_fee) = max_fee {
                         if quote.fee_reserve > max_fee {
@@ -1938,7 +2484,10 @@ impl MultiMintWallet {
         }
 
         if let (Some(quote), Some(wallet)) = (best_quote, best_wallet) {
-            return wallet.melt(&quote.id).await;
+            let prepared = wallet
+                .prepare_melt(&quote.id, std::collections::HashMap::new())
+                .await?;
+            return prepared.confirm().await;
         }
 
         Err(Error::InsufficientFunds)
@@ -2362,7 +2911,7 @@ mod tests {
     }
 
     #[tokio::test]
-    async fn test_prepare_send_insufficient_funds() {
+    async fn test_send_insufficient_funds() {
         use std::str::FromStr;
 
         let multi_wallet = create_test_multi_wallet().await;
@@ -2370,7 +2919,7 @@ mod tests {
         let options = MultiMintSendOptions::new();
 
         let result = multi_wallet
-            .prepare_send(mint_url, Amount::from(1000), options)
+            .send(mint_url, Amount::from(1000), options)
             .await;
 
         assert!(result.is_err());

+ 2 - 1
crates/cdk/src/wallet/payment_request.rs

@@ -5,6 +5,7 @@
 //! is returned so callers can handle alternative delivery mechanisms explicitly.
 
 use std::str::FromStr;
+use std::sync::Arc;
 
 use anyhow::Result;
 use bitcoin::hashes::sha256::Hash as Sha256Hash;
@@ -306,7 +307,7 @@ impl MultiMintWallet {
         } else {
             // No mint specified - find the best matching mint with highest balance
             let balances = self.get_balances().await?;
-            let mut best_wallet: Option<Wallet> = None;
+            let mut best_wallet: Option<Arc<Wallet>> = None;
             let mut best_balance = Amount::ZERO;
 
             for (mint_url, balance) in balances.iter() {

+ 41 - 45
crates/cdk/src/wallet/proofs.rs

@@ -1,17 +1,15 @@
 use std::collections::{HashMap, HashSet};
 
 use cdk_common::amount::KeysetFeeAndAmounts;
-use cdk_common::wallet::TransactionId;
+use cdk_common::wallet::ProofInfo;
 use cdk_common::Id;
 use tracing::instrument;
 
-use crate::amount::SplitTarget;
 use crate::fees::calculate_fee;
 use crate::nuts::nut00::ProofsMethods;
 use crate::nuts::{
     CheckStateRequest, Proof, ProofState, Proofs, PublicKey, SpendingConditions, State,
 };
-use crate::types::ProofInfo;
 use crate::{ensure_cdk, Amount, Error, Wallet};
 
 impl Wallet {
@@ -70,41 +68,6 @@ impl Wallet {
         Ok(())
     }
 
-    /// Reclaim unspent proofs
-    ///
-    /// Checks the stats of [`Proofs`] swapping for a new [`Proof`] if unspent
-    #[instrument(skip(self, proofs))]
-    pub async fn reclaim_unspent(&self, proofs: Proofs) -> Result<(), Error> {
-        let proof_ys = proofs.ys()?;
-
-        let transaction_id = TransactionId::new(proof_ys.clone());
-
-        let spendable = self
-            .client
-            .post_check_state(CheckStateRequest { ys: proof_ys })
-            .await?
-            .states;
-
-        let unspent: Proofs = proofs
-            .into_iter()
-            .zip(spendable)
-            .filter_map(|(p, s)| (s.state == State::Unspent).then_some(p))
-            .collect();
-
-        self.swap(None, SplitTarget::default(), unspent, None, false)
-            .await?;
-
-        let _ = self
-            .localstore
-            .remove_transaction(transaction_id)
-            .await
-            .inspect_err(|err| {
-                tracing::warn!("Failed to remove transaction: {:?}", err);
-            });
-
-        Ok(())
-    }
-
     /// NUT-07 Check the state of a [`Proof`] with the mint
     #[instrument(skip(self, proofs))]
     pub async fn check_proofs_spent(&self, proofs: Proofs) -> Result<Vec<ProofState>, Error> {
@@ -127,7 +90,27 @@ impl Wallet {
         Ok(spendable.states)
     }
 
-    /// Checks pending proofs for spent status
+    /// Checks pending proofs for spent status and marks spent proofs accordingly.
+    ///
+    /// # Legacy Recovery Function
+    ///
+    /// This function is intended for recovering **orphaned proofs** that were stuck
+    /// in `Pending`, `Reserved`, or `PendingSpent` state before the saga pattern
+    /// was implemented, or proofs whose saga was deleted without proper cleanup.
+    ///
+    /// **Important**: This function only operates on proofs that are NOT currently
+    /// associated with an active saga operation (i.e., `used_by_operation` is `None`).
+    /// Proofs managed by active sagas are skipped to avoid interfering with in-flight
+    /// operations.
+    ///
+    /// For proofs that are part of active sagas, use the appropriate saga recovery
+    /// mechanism (`recover_incomplete_sagas`) or saga-specific methods like
+    /// `SendSaga::revoke()`.
+    ///
+    /// # Returns
+    ///
+    /// The total amount of orphaned proofs that remain pending (not spent by the mint)
+    /// after checking.
     #[instrument(skip(self))]
     pub async fn check_all_pending_proofs(&self) -> Result<Amount, Error> {
         let mut balance = Amount::ZERO;
@@ -142,12 +125,24 @@ impl Wallet {
             )
             .await?;
 
-        if proofs.is_empty() {
+        // Filter out proofs that are managed by active sagas
+        let orphaned_proofs: Vec<ProofInfo> = proofs
+            .into_iter()
+            .filter(|p| p.used_by_operation.is_none())
+            .collect();
+
+        if orphaned_proofs.is_empty() {
             return Ok(Amount::ZERO);
         }
 
         let states = self
-            .check_proofs_spent(proofs.clone().into_iter().map(|p| p.proof).collect())
+            .check_proofs_spent(
+                orphaned_proofs
+                    .clone()
+                    .into_iter()
+                    .map(|p| p.proof)
+                    .collect(),
+            )
             .await?;
 
         // Both `State::Pending` and `State::Unspent` should be included in the pending
@@ -160,9 +155,10 @@ impl Wallet {
             .map(|s| s.y)
             .collect();
 
-        let (pending_proofs, non_pending_proofs): (Vec<ProofInfo>, Vec<ProofInfo>) = proofs
-            .into_iter()
-            .partition(|p| pending_states.contains(&p.y));
+        let (pending_proofs, non_pending_proofs): (Vec<ProofInfo>, Vec<ProofInfo>) =
+            orphaned_proofs
+                .into_iter()
+                .partition(|p| pending_states.contains(&p.y));
 
         let amount = Amount::try_sum(pending_proofs.iter().map(|p| p.proof.amount))?;
 
@@ -215,7 +211,7 @@ impl Wallet {
             //
             // The first step is to sort the proofs, select the one with the biggest amount, and
             // perform a swap requesting the exact amount (covering the swap fees).
-            input_proofs.sort_by(|a, b| a.amount.cmp(&b.amount));
+            input_proofs.sort_by_key(|a| a.amount);
 
             if let Some(proof_to_exchange) = input_proofs.pop() {
                 let fee_ppk = fees_and_keyset_amounts

+ 0 - 308
crates/cdk/src/wallet/receive.rs

@@ -1,308 +0,0 @@
-use std::collections::HashMap;
-use std::str::FromStr;
-
-use bitcoin::hashes::sha256::Hash as Sha256Hash;
-use bitcoin::hashes::Hash;
-use bitcoin::XOnlyPublicKey;
-use cdk_common::util::unix_time;
-use cdk_common::wallet::{Transaction, TransactionDirection};
-use tracing::instrument;
-
-use crate::amount::SplitTarget;
-use crate::dhke::construct_proofs;
-use crate::nuts::nut00::ProofsMethods;
-use crate::nuts::nut10::Kind;
-use crate::nuts::{Conditions, Proofs, PublicKey, SecretKey, SigFlag, State, Token};
-use crate::types::ProofInfo;
-use crate::util::hex;
-use crate::{ensure_cdk, Amount, Error, Wallet, SECP256K1};
-
-impl Wallet {
-    /// Receive proofs
-    #[instrument(skip_all)]
-    pub async fn receive_proofs(
-        &self,
-        proofs: Proofs,
-        opts: ReceiveOptions,
-        memo: Option<String>,
-    ) -> Result<Amount, Error> {
-        // Incase the wallet is getting ecash for the first time
-        // we want to get the mint info for our db
-        let _mint_info = self.load_mint_info().await?;
-
-        let mint_url = &self.mint_url;
-
-        let active_keyset_id = self.fetch_active_keyset().await?.id;
-        let fee_and_amounts = self
-            .get_keyset_fees_and_amounts_by_id(active_keyset_id)
-            .await?;
-
-        let keys = self.load_keyset_keys(active_keyset_id).await?;
-
-        let mut proofs = proofs;
-
-        let proofs_amount = proofs.total_amount()?;
-        let proofs_ys = proofs.ys()?;
-
-        let mut sig_flag = SigFlag::SigInputs;
-
-        // Map hash of preimage to preimage
-        let hashed_to_preimage: HashMap<String, &String> = opts
-            .preimages
-            .iter()
-            .map(|p| {
-                let hex_bytes = hex::decode(p)?;
-                Ok::<(String, &String), Error>((Sha256Hash::hash(&hex_bytes).to_string(), p))
-            })
-            .collect::<Result<HashMap<String, &String>, _>>()?;
-
-        let p2pk_signing_keys: HashMap<XOnlyPublicKey, &SecretKey> = opts
-            .p2pk_signing_keys
-            .iter()
-            .map(|s| (s.x_only_public_key(&SECP256K1).0, s))
-            .collect();
-
-        for proof in &mut proofs {
-            // Verify that proof DLEQ is valid
-            if proof.dleq.is_some() {
-                let keys = self.load_keyset_keys(proof.keyset_id).await?;
-                let key = keys.amount_key(proof.amount).ok_or(Error::AmountKey)?;
-                proof.verify_dleq(key)?;
-            }
-
-            if let Ok(secret) =
-                <crate::secret::Secret as TryInto<crate::nuts::nut10::Secret>>::try_into(
-                    proof.secret.clone(),
-                )
-            {
-                let conditions: Result<Conditions, _> = secret
-                    .secret_data()
-                    .tags()
-                    .cloned()
-                    .unwrap_or_default()
-                    .try_into();
-                if let Ok(conditions) = conditions {
-                    let mut pubkeys = conditions.pubkeys.unwrap_or_default();
-
-                    match secret.kind() {
-                        Kind::P2PK => {
-                            let data_key = PublicKey::from_str(secret.secret_data().data())?;
-
-                            pubkeys.push(data_key);
-                        }
-                        Kind::HTLC => {
-                            let hashed_preimage = secret.secret_data().data();
-                            let preimage = hashed_to_preimage
-                                .get(hashed_preimage)
-                                .ok_or(Error::PreimageNotProvided)?;
-                            proof.add_preimage(preimage.to_string());
-                        }
-                    }
-                    for pubkey in pubkeys {
-                        if let Some(signing) = p2pk_signing_keys.get(&pubkey.x_only_public_key()) {
-                            proof.sign_p2pk(signing.to_owned().clone())?;
-                        }
-                    }
-
-                    if conditions.sig_flag.eq(&SigFlag::SigAll) {
-                        sig_flag = SigFlag::SigAll;
-                    }
-                }
-            }
-        }
-
-        let fee_breakdown = self.get_proofs_fee(&proofs).await?;
-
-        // Since the proofs are unknown they need to be added to the database
-        let proofs_info = proofs
-            .clone()
-            .into_iter()
-            .map(|p| ProofInfo::new(p, self.mint_url.clone(), State::Pending, self.unit.clone()))
-            .collect::<Result<Vec<ProofInfo>, _>>()?;
-
-        self.localstore
-            .update_proofs(proofs_info.clone(), vec![])
-            .await?;
-
-        let mut pre_swap = self
-            .create_swap(
-                active_keyset_id,
-                &fee_and_amounts,
-                None,
-                opts.amount_split_target,
-                proofs,
-                None,
-                false,
-                &fee_breakdown,
-            )
-            .await?;
-
-        if sig_flag.eq(&SigFlag::SigAll) {
-            for blinded_message in pre_swap.swap_request.outputs_mut() {
-                for signing_key in p2pk_signing_keys.values() {
-                    blinded_message.sign_p2pk(signing_key.to_owned().clone())?
-                }
-            }
-        }
-
-        let swap_response = match self.client.post_swap(pre_swap.swap_request).await {
-            Ok(response) => response,
-            Err(err) => {
-                tracing::error!("Failed to post swap request: {}", err);
-
-                // Remove the pending proofs we added since the swap failed
-                self.localstore
-                    .update_proofs(vec![], proofs_info.into_iter().map(|p| p.y).collect())
-                    .await?;
-
-                return Err(err);
-            }
-        };
-
-        // Proof to keep
-        let recv_proofs = construct_proofs(
-            swap_response.signatures,
-            pre_swap.pre_mint_secrets.rs(),
-            pre_swap.pre_mint_secrets.secrets(),
-            &keys,
-        )?;
-
-        self.localstore
-            .increment_keyset_counter(&active_keyset_id, recv_proofs.len() as u32)
-            .await?;
-
-        let total_amount = recv_proofs.total_amount()?;
-
-        let recv_proof_infos = recv_proofs
-            .into_iter()
-            .map(|proof| ProofInfo::new(proof, mint_url.clone(), State::Unspent, self.unit.clone()))
-            .collect::<Result<Vec<ProofInfo>, _>>()?;
-
-        self.localstore
-            .update_proofs(
-                recv_proof_infos,
-                proofs_info.into_iter().map(|p| p.y).collect(),
-            )
-            .await?;
-
-        // Add transaction to store
-        self.localstore
-            .add_transaction(Transaction {
-                mint_url: self.mint_url.clone(),
-                direction: TransactionDirection::Incoming,
-                amount: total_amount,
-                fee: proofs_amount - total_amount,
-                unit: self.unit.clone(),
-                ys: proofs_ys,
-                timestamp: unix_time(),
-                memo,
-                metadata: opts.metadata,
-                quote_id: None,
-                payment_request: None,
-                payment_proof: None,
-                payment_method: None,
-            })
-            .await?;
-
-        Ok(total_amount)
-    }
-
-    /// Receive
-    /// # Synopsis
-    /// ```rust, no_run
-    ///  use std::sync::Arc;
-    ///
-    ///  use cdk::amount::SplitTarget;
-    ///  use cdk_sqlite::wallet::memory;
-    ///  use cdk::nuts::CurrencyUnit;
-    ///  use cdk::wallet::{ReceiveOptions, Wallet};
-    ///  use rand::random;
-    ///
-    /// #[tokio::main]
-    /// async fn main() -> anyhow::Result<()> {
-    ///  let seed = random::<[u8; 64]>();
-    ///  let mint_url = "https://fake.thesimplekid.dev";
-    ///  let unit = CurrencyUnit::Sat;
-    ///
-    ///  let localstore = memory::empty().await?;
-    ///  let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None).unwrap();
-    ///  let token = "cashuAeyJ0b2tlbiI6W3sicHJvb2ZzIjpbeyJhbW91bnQiOjEsInNlY3JldCI6ImI0ZjVlNDAxMDJhMzhiYjg3NDNiOTkwMzU5MTU1MGYyZGEzZTQxNWEzMzU0OTUyN2M2MmM5ZDc5MGVmYjM3MDUiLCJDIjoiMDIzYmU1M2U4YzYwNTMwZWVhOWIzOTQzZmRhMWEyY2U3MWM3YjNmMGNmMGRjNmQ4NDZmYTc2NWFhZjc3OWZhODFkIiwiaWQiOiIwMDlhMWYyOTMyNTNlNDFlIn1dLCJtaW50IjoiaHR0cHM6Ly90ZXN0bnV0LmNhc2h1LnNwYWNlIn1dLCJ1bml0Ijoic2F0In0=";
-    ///  let amount_receive = wallet.receive(token, ReceiveOptions::default()).await?;
-    ///  Ok(())
-    /// }
-    /// ```
-    #[instrument(skip_all)]
-    pub async fn receive(
-        &self,
-        encoded_token: &str,
-        opts: ReceiveOptions,
-    ) -> Result<Amount, Error> {
-        let token = Token::from_str(encoded_token)?;
-        let unit = token.unit().unwrap_or_default();
-
-        ensure_cdk!(unit == self.unit, Error::UnsupportedUnit);
-
-        let keysets_info = self.load_mint_keysets().await?;
-        let proofs = token.proofs(&keysets_info)?;
-
-        if let Token::TokenV3(token) = &token {
-            ensure_cdk!(!token.is_multi_mint(), Error::MultiMintTokenNotSupported);
-        }
-
-        ensure_cdk!(self.mint_url == token.mint_url()?, Error::IncorrectMint);
-
-        let amount = self
-            .receive_proofs(proofs, opts, token.memo().clone())
-            .await?;
-
-        Ok(amount)
-    }
-
-    /// Receive
-    /// # Synopsis
-    /// ```rust, no_run
-    ///  use std::sync::Arc;
-    ///
-    ///  use cdk::amount::SplitTarget;
-    ///  use cdk_sqlite::wallet::memory;
-    ///  use cdk::nuts::CurrencyUnit;
-    ///  use cdk::wallet::{ReceiveOptions, Wallet};
-    ///  use cdk::util::hex;
-    ///  use rand::random;
-    ///
-    /// #[tokio::main]
-    /// async fn main() -> anyhow::Result<()> {
-    ///  let seed = random::<[u8; 64]>();
-    ///  let mint_url = "https://fake.thesimplekid.dev";
-    ///  let unit = CurrencyUnit::Sat;
-    ///
-    ///  let localstore = memory::empty().await?;
-    ///  let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None).unwrap();
-    ///  let token_raw = hex::decode("6372617742a4617481a261694800ad268c4d1f5826617081a3616101617378403961366462623834376264323332626137366462306466313937323136623239643362386363313435353363643237383237666331636339343266656462346561635821038618543ffb6b8695df4ad4babcde92a34a96bdcd97dcee0d7ccf98d4721267926164695468616e6b20796f75616d75687474703a2f2f6c6f63616c686f73743a33333338617563736174").unwrap();
-    ///  let amount_receive = wallet.receive_raw(&token_raw, ReceiveOptions::default()).await?;
-    ///  Ok(())
-    /// }
-    /// ```
-    #[instrument(skip_all)]
-    pub async fn receive_raw(
-        &self,
-        binary_token: &Vec<u8>,
-        opts: ReceiveOptions,
-    ) -> Result<Amount, Error> {
-        let token_str = Token::try_from(binary_token)?.to_string();
-        self.receive(token_str.as_str(), opts).await
-    }
-}
-
-/// Receive options
-#[derive(Debug, Clone, Default)]
-pub struct ReceiveOptions {
-    /// Amount split target
-    pub amount_split_target: SplitTarget,
-    /// P2PK signing keys
-    pub p2pk_signing_keys: Vec<SecretKey>,
-    /// Preimages
-    pub preimages: Vec<String>,
-    /// Metadata
-    pub metadata: HashMap<String, String>,
-}

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.