Parcourir la source

Merge remote-tracking branch 'origin/main' into feature/wallet-db-transactions

Cesar Rodas il y a 1 mois
Parent
commit
d3222aedf3
30 fichiers modifiés avec 2050 ajouts et 74 suppressions
  1. 1 1
      .github/workflows/docker-publish-arm.yml
  2. 1 1
      .github/workflows/docker-publish-ldk-node-arm.yml
  3. 1 1
      .github/workflows/docker-publish-ldk-node.yml
  4. 1 1
      .github/workflows/docker-publish.yml
  5. 1 1
      crates/cdk-ffi/Cargo.toml
  6. 113 0
      crates/cdk-ffi/src/multi_mint_wallet.rs
  7. 2 0
      crates/cdk-ffi/src/types/mod.rs
  8. 370 0
      crates/cdk-ffi/src/types/payment_request.rs
  9. 24 0
      crates/cdk-ffi/src/wallet.rs
  10. 1 1
      crates/cdk-integration-tests/src/init_pure_tests.rs
  11. 49 4
      crates/cdk-integration-tests/src/init_regtest.rs
  12. 127 0
      crates/cdk-integration-tests/tests/fake_wallet.rs
  13. 498 1
      crates/cdk-integration-tests/tests/test_swap_flow.rs
  14. 33 0
      crates/cdk-sql-common/src/mint/mod.rs
  15. 4 0
      crates/cdk/Cargo.toml
  16. 252 0
      crates/cdk/examples/payment_request.rs
  17. 8 2
      crates/cdk/src/mint/melt/melt_saga/compensation.rs
  18. 1 0
      crates/cdk/src/mint/melt/melt_saga/mod.rs
  19. 101 0
      crates/cdk/src/mint/melt/melt_saga/tests.rs
  20. 19 3
      crates/cdk/src/mint/melt/shared.rs
  21. 3 10
      crates/cdk/src/mint/start_up_check.rs
  22. 17 10
      crates/cdk/src/mint/swap/mod.rs
  23. 22 2
      crates/cdk/src/mint/swap/swap_saga/compensation.rs
  24. 1 21
      crates/cdk/src/mint/swap/swap_saga/mod.rs
  25. 19 3
      crates/cdk/src/mint/verification.rs
  26. 113 8
      crates/cdk/src/wallet/melt/melt_bolt11.rs
  27. 15 1
      crates/cdk/src/wallet/multi_mint_wallet.rs
  28. 3 3
      crates/cdk/src/wallet/reclaim.rs
  29. 186 0
      crates/cdk/src/wallet/send.rs
  30. 64 0
      meetings/2025-12-03-agenda.md

+ 1 - 1
.github/workflows/docker-publish-arm.yml

@@ -40,7 +40,7 @@ jobs:
         with:
           images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
           tags: |
-            type=raw,value=latest-arm64,enable=${{ github.event_name == 'release' }}
+            type=raw,value=latest-arm64,enable=${{ github.event_name == 'release' && !github.event.release.prerelease && !contains(github.ref_name, 'rc') }}
             type=semver,pattern={{version}}-arm64
             type=semver,pattern={{major}}.{{minor}}-arm64
             type=ref,event=branch,suffix=-arm64

+ 1 - 1
.github/workflows/docker-publish-ldk-node-arm.yml

@@ -40,7 +40,7 @@ jobs:
         with:
           images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
           tags: |
-            type=raw,value=ldk-node-arm64,enable=${{ github.event_name == 'release' }}
+            type=raw,value=ldk-node-arm64,enable=${{ github.event_name == 'release' && !github.event.release.prerelease && !contains(github.ref_name, 'rc') }}
             type=semver,pattern={{version}}-ldk-node-arm64
             type=semver,pattern={{major}}.{{minor}}-ldk-node-arm64
             type=ref,event=branch,suffix=-ldk-node-arm64

+ 1 - 1
.github/workflows/docker-publish-ldk-node.yml

@@ -40,7 +40,7 @@ jobs:
         with:
           images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
           tags: |
-            type=raw,value=ldk-node,enable=${{ github.event_name == 'release' }}
+            type=raw,value=ldk-node,enable=${{ github.event_name == 'release' && !github.event.release.prerelease && !contains(github.ref_name, 'rc') }}
             type=semver,pattern={{version}}-ldk-node
             type=semver,pattern={{major}}.{{minor}}-ldk-node
             type=ref,event=branch,suffix=-ldk-node

+ 1 - 1
.github/workflows/docker-publish.yml

@@ -40,7 +40,7 @@ jobs:
         with:
           images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
           tags: |
-            type=raw,value=latest,enable=${{ github.event_name == 'release' }}
+            type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease && !contains(github.ref_name, 'rc') }}
             type=semver,pattern={{version}}
             type=semver,pattern={{major}}.{{minor}}
             type=ref,event=branch

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

@@ -15,7 +15,7 @@ name = "cdk_ffi"
 [dependencies]
 async-trait = { workspace = true }
 bip39 = { workspace = true }
-cdk = { workspace = true, default-features = false, features = ["wallet", "auth", "bip353"] }
+cdk = { workspace = true, default-features = false, features = ["wallet", "auth", "bip353", "nostr"] }
 cdk-sqlite = { workspace = true }
 cdk-postgres = { workspace = true, optional = true }
 futures = { workspace = true }

+ 113 - 0
crates/cdk-ffi/src/multi_mint_wallet.rs

@@ -14,6 +14,9 @@ use cdk::wallet::multi_mint_wallet::{
 
 use crate::error::FfiError;
 use crate::token::Token;
+use crate::types::payment_request::{
+    CreateRequestParams, CreateRequestResult, NostrWaitInfo, PaymentRequest,
+};
 use crate::types::*;
 
 /// FFI-compatible MultiMintWallet
@@ -248,6 +251,25 @@ impl MultiMintWallet {
         Ok(proofs_by_mint)
     }
 
+    /// Check the state of proofs at a specific mint
+    pub async fn check_proofs_state(
+        &self,
+        mint_url: MintUrl,
+        proofs: Proofs,
+    ) -> Result<Vec<ProofState>, 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 states = self
+            .inner
+            .check_proofs_state(&cdk_mint_url, cdk_proofs)
+            .await?;
+
+        Ok(states.into_iter().map(|s| s.into()).collect())
+    }
+
     /// Receive token
     pub async fn receive(
         &self,
@@ -642,6 +664,97 @@ impl MultiMintWallet {
     }
 }
 
+/// Payment request methods for MultiMintWallet
+#[uniffi::export(async_runtime = "tokio")]
+impl MultiMintWallet {
+    /// Create a NUT-18 payment request
+    ///
+    /// Creates a payment request that can be shared to receive Cashu tokens.
+    /// The request can include optional amount, description, and spending conditions.
+    ///
+    /// # Arguments
+    ///
+    /// * `params` - Parameters for creating the payment request
+    ///
+    /// # Transport Options
+    ///
+    /// - `"nostr"` - Uses Nostr relays for privacy-preserving delivery (requires nostr_relays)
+    /// - `"http"` - Uses HTTP POST for delivery (requires http_url)
+    /// - `"none"` - No transport; token must be delivered out-of-band
+    ///
+    /// # Example
+    ///
+    /// ```ignore
+    /// let params = CreateRequestParams {
+    ///     amount: Some(100),
+    ///     unit: "sat".to_string(),
+    ///     description: Some("Coffee payment".to_string()),
+    ///     transport: "http".to_string(),
+    ///     http_url: Some("https://example.com/callback".to_string()),
+    ///     ..Default::default()
+    /// };
+    /// let result = wallet.create_request(params).await?;
+    /// println!("Share this request: {}", result.payment_request.to_string_encoded());
+    ///
+    /// // If using Nostr transport, wait for payment:
+    /// if let Some(nostr_info) = result.nostr_wait_info {
+    ///     let amount = wallet.wait_for_nostr_payment(nostr_info).await?;
+    ///     println!("Received {} sats", amount);
+    /// }
+    /// ```
+    pub async fn create_request(
+        &self,
+        params: CreateRequestParams,
+    ) -> Result<CreateRequestResult, FfiError> {
+        let (payment_request, nostr_wait_info) = self.inner.create_request(params.into()).await?;
+        Ok(CreateRequestResult {
+            payment_request: Arc::new(PaymentRequest::from_inner(payment_request)),
+            nostr_wait_info: nostr_wait_info.map(|info| Arc::new(NostrWaitInfo::from_inner(info))),
+        })
+    }
+
+    /// Wait for a Nostr payment and receive it into the wallet
+    ///
+    /// This method connects to the Nostr relays specified in the `NostrWaitInfo`,
+    /// subscribes for incoming payment events, and receives the first valid
+    /// payment into the wallet.
+    ///
+    /// # Arguments
+    ///
+    /// * `info` - The Nostr wait info returned from `create_request` when using Nostr transport
+    ///
+    /// # Returns
+    ///
+    /// The amount received from the payment.
+    ///
+    /// # Example
+    ///
+    /// ```ignore
+    /// let result = wallet.create_request(params).await?;
+    /// if let Some(nostr_info) = result.nostr_wait_info {
+    ///     let amount = wallet.wait_for_nostr_payment(nostr_info).await?;
+    ///     println!("Received {} sats", amount);
+    /// }
+    /// ```
+    pub async fn wait_for_nostr_payment(
+        &self,
+        info: Arc<NostrWaitInfo>,
+    ) -> Result<Amount, FfiError> {
+        // We need to clone the inner NostrWaitInfo since we can't consume the Arc
+        let info_inner = cdk::wallet::payment_request::NostrWaitInfo {
+            keys: info.inner().keys.clone(),
+            relays: info.inner().relays.clone(),
+            pubkey: info.inner().pubkey,
+        };
+        let amount = self
+            .inner
+            .wait_for_nostr_payment(info_inner)
+            .await
+            .map_err(|e| FfiError::Generic { msg: e.to_string() })?;
+        Ok(amount.into())
+    }
+}
+
 /// Auth methods for MultiMintWallet
 #[uniffi::export(async_runtime = "tokio")]
 impl MultiMintWallet {

+ 2 - 0
crates/cdk-ffi/src/types/mod.rs

@@ -8,6 +8,7 @@ pub mod amount;
 pub mod invoice;
 pub mod keys;
 pub mod mint;
+pub mod payment_request;
 pub mod proof;
 pub mod quote;
 pub mod subscription;
@@ -19,6 +20,7 @@ pub use amount::*;
 pub use invoice::*;
 pub use keys::*;
 pub use mint::*;
+pub use payment_request::*;
 pub use proof::*;
 pub use quote::*;
 pub use subscription::*;

+ 370 - 0
crates/cdk-ffi/src/types/payment_request.rs

@@ -0,0 +1,370 @@
+//! Payment Request FFI types (NUT-18)
+
+use std::sync::Arc;
+
+use serde::{Deserialize, Serialize};
+
+use super::amount::{Amount, CurrencyUnit};
+use crate::error::FfiError;
+
+/// Transport type for payment request delivery
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
+pub enum TransportType {
+    /// Nostr transport (privacy-preserving)
+    Nostr,
+    /// HTTP POST transport
+    HttpPost,
+}
+
+impl From<cdk::nuts::TransportType> for TransportType {
+    fn from(t: cdk::nuts::TransportType) -> Self {
+        match t {
+            cdk::nuts::TransportType::Nostr => TransportType::Nostr,
+            cdk::nuts::TransportType::HttpPost => TransportType::HttpPost,
+        }
+    }
+}
+
+impl From<TransportType> for cdk::nuts::TransportType {
+    fn from(t: TransportType) -> Self {
+        match t {
+            TransportType::Nostr => cdk::nuts::TransportType::Nostr,
+            TransportType::HttpPost => cdk::nuts::TransportType::HttpPost,
+        }
+    }
+}
+
+/// Transport for payment request delivery
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct Transport {
+    /// Transport type
+    pub transport_type: TransportType,
+    /// Target (e.g., nprofile for Nostr, URL for HTTP)
+    pub target: String,
+    /// Optional tags
+    pub tags: Option<Vec<Vec<String>>>,
+}
+
+impl From<cdk::nuts::Transport> for Transport {
+    fn from(t: cdk::nuts::Transport) -> Self {
+        Self {
+            transport_type: t._type.into(),
+            target: t.target,
+            tags: t.tags,
+        }
+    }
+}
+
+impl From<Transport> for cdk::nuts::Transport {
+    fn from(t: Transport) -> Self {
+        Self {
+            _type: t.transport_type.into(),
+            target: t.target,
+            tags: t.tags,
+        }
+    }
+}
+
+/// NUT-18 Payment Request
+///
+/// A payment request that can be shared to request Cashu tokens.
+/// Encoded as a string with the `creqA` prefix.
+#[derive(uniffi::Object)]
+pub struct PaymentRequest {
+    inner: cdk::nuts::PaymentRequest,
+}
+
+impl PaymentRequest {
+    /// Create from inner CDK type
+    pub(crate) fn from_inner(inner: cdk::nuts::PaymentRequest) -> Self {
+        Self { inner }
+    }
+
+    /// Get inner reference
+    pub(crate) fn inner(&self) -> &cdk::nuts::PaymentRequest {
+        &self.inner
+    }
+}
+
+#[uniffi::export]
+impl PaymentRequest {
+    /// Parse a payment request from its encoded string representation
+    #[uniffi::constructor]
+    pub fn from_string(encoded: String) -> Result<Arc<Self>, FfiError> {
+        use std::str::FromStr;
+        let inner = cdk::nuts::PaymentRequest::from_str(&encoded)
+            .map_err(|e| FfiError::Generic { msg: e.to_string() })?;
+        Ok(Arc::new(Self { inner }))
+    }
+
+    /// Encode the payment request to a string
+    pub fn to_string_encoded(&self) -> String {
+        self.inner.to_string()
+    }
+
+    /// Get the payment ID
+    pub fn payment_id(&self) -> Option<String> {
+        self.inner.payment_id.clone()
+    }
+
+    /// Get the requested amount
+    pub fn amount(&self) -> Option<Amount> {
+        self.inner.amount.map(|a| a.into())
+    }
+
+    /// Get the currency unit
+    pub fn unit(&self) -> Option<CurrencyUnit> {
+        self.inner.unit.clone().map(|u| u.into())
+    }
+
+    /// Get whether this is a single-use request
+    pub fn single_use(&self) -> Option<bool> {
+        self.inner.single_use
+    }
+
+    /// Get the list of acceptable mint URLs
+    pub fn mints(&self) -> Option<Vec<String>> {
+        self.inner
+            .mints
+            .as_ref()
+            .map(|mints| mints.iter().map(|m| m.to_string()).collect())
+    }
+
+    /// Get the description
+    pub fn description(&self) -> Option<String> {
+        self.inner.description.clone()
+    }
+
+    /// Get the transports for delivering the payment
+    pub fn transports(&self) -> Vec<Transport> {
+        self.inner
+            .transports
+            .iter()
+            .cloned()
+            .map(|t| t.into())
+            .collect()
+    }
+}
+
+/// Parameters for creating a NUT-18 payment request
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct CreateRequestParams {
+    /// Optional amount to request (in smallest unit for the currency)
+    pub amount: Option<u64>,
+    /// Currency unit (e.g., "sat", "msat", "usd")
+    pub unit: String,
+    /// Optional description for the request
+    pub description: Option<String>,
+    /// Optional public keys for P2PK spending conditions (hex-encoded)
+    pub pubkeys: Option<Vec<String>>,
+    /// Required number of signatures for multisig (defaults to 1)
+    pub num_sigs: u64,
+    /// Optional HTLC hash (hex-encoded SHA-256)
+    pub hash: Option<String>,
+    /// Optional HTLC preimage (alternative to hash)
+    pub preimage: Option<String>,
+    /// Transport type: "nostr", "http", or "none"
+    pub transport: String,
+    /// HTTP URL for HTTP transport (required if transport is "http")
+    pub http_url: Option<String>,
+    /// Nostr relay URLs (required if transport is "nostr")
+    pub nostr_relays: Option<Vec<String>>,
+}
+
+impl Default for CreateRequestParams {
+    fn default() -> Self {
+        Self {
+            amount: None,
+            unit: "sat".to_string(),
+            description: None,
+            pubkeys: None,
+            num_sigs: 1,
+            hash: None,
+            preimage: None,
+            transport: "none".to_string(),
+            http_url: None,
+            nostr_relays: None,
+        }
+    }
+}
+
+impl From<CreateRequestParams> for cdk::wallet::payment_request::CreateRequestParams {
+    fn from(params: CreateRequestParams) -> Self {
+        Self {
+            amount: params.amount,
+            unit: params.unit,
+            description: params.description,
+            pubkeys: params.pubkeys,
+            num_sigs: params.num_sigs,
+            hash: params.hash,
+            preimage: params.preimage,
+            transport: params.transport,
+            http_url: params.http_url,
+            nostr_relays: params.nostr_relays,
+        }
+    }
+}
+
+impl From<cdk::wallet::payment_request::CreateRequestParams> for CreateRequestParams {
+    fn from(params: cdk::wallet::payment_request::CreateRequestParams) -> Self {
+        Self {
+            amount: params.amount,
+            unit: params.unit,
+            description: params.description,
+            pubkeys: params.pubkeys,
+            num_sigs: params.num_sigs,
+            hash: params.hash,
+            preimage: params.preimage,
+            transport: params.transport,
+            http_url: params.http_url,
+            nostr_relays: params.nostr_relays,
+        }
+    }
+}
+
+/// Decode a payment request from its encoded string representation
+#[uniffi::export]
+pub fn decode_payment_request(encoded: String) -> Result<Arc<PaymentRequest>, FfiError> {
+    PaymentRequest::from_string(encoded)
+}
+
+/// Encode CreateRequestParams to JSON string
+#[uniffi::export]
+pub fn encode_create_request_params(params: CreateRequestParams) -> Result<String, FfiError> {
+    Ok(serde_json::to_string(&params)?)
+}
+
+/// Decode CreateRequestParams from JSON string
+#[uniffi::export]
+pub fn decode_create_request_params(json: String) -> Result<CreateRequestParams, FfiError> {
+    Ok(serde_json::from_str(&json)?)
+}
+
+/// Information needed to wait for an incoming Nostr payment
+///
+/// Returned by `create_request` when the transport is `nostr`. Pass this to
+/// `wait_for_nostr_payment` to connect, subscribe, and receive the incoming
+/// payment on the specified relays.
+#[derive(uniffi::Object)]
+pub struct NostrWaitInfo {
+    inner: cdk::wallet::payment_request::NostrWaitInfo,
+}
+
+impl NostrWaitInfo {
+    /// Create from inner CDK type
+    pub(crate) fn from_inner(inner: cdk::wallet::payment_request::NostrWaitInfo) -> Self {
+        Self { inner }
+    }
+
+    /// Get inner reference
+    pub(crate) fn inner(&self) -> &cdk::wallet::payment_request::NostrWaitInfo {
+        &self.inner
+    }
+}
+
+#[uniffi::export]
+impl NostrWaitInfo {
+    /// Get the Nostr relays to connect to
+    pub fn relays(&self) -> Vec<String> {
+        self.inner.relays.clone()
+    }
+
+    /// Get the recipient public key as a hex string
+    pub fn pubkey(&self) -> String {
+        self.inner.pubkey.to_hex()
+    }
+}
+
+/// Result of creating a payment request
+///
+/// Contains the payment request and optionally the Nostr wait info
+/// if the transport was set to "nostr".
+#[derive(uniffi::Record)]
+pub struct CreateRequestResult {
+    /// The payment request to share with the payer
+    pub payment_request: Arc<PaymentRequest>,
+    /// Nostr wait info (present when transport is "nostr")
+    pub nostr_wait_info: Option<Arc<NostrWaitInfo>>,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const PAYMENT_REQUEST: &str = "creqApWF0gaNhdGVub3N0cmFheKlucHJvZmlsZTFxeTI4d3VtbjhnaGo3dW45ZDNzaGp0bnl2OWtoMnVld2Q5aHN6OW1od2RlbjV0ZTB3ZmprY2N0ZTljdXJ4dmVuOWVlaHFjdHJ2NWhzenJ0aHdkZW41dGUwZGVoaHh0bnZkYWtxcWd5ZGFxeTdjdXJrNDM5eWtwdGt5c3Y3dWRoZGh1NjhzdWNtMjk1YWtxZWZkZWhrZjBkNDk1Y3d1bmw1YWeBgmFuYjE3YWloYjdhOTAxNzZhYQphdWNzYXRhbYF4Imh0dHBzOi8vbm9mZWVzLnRlc3RudXQuY2FzaHUuc3BhY2U=";
+
+    #[test]
+    fn test_decode_payment_request() {
+        let req = PaymentRequest::from_string(PAYMENT_REQUEST.to_string()).unwrap();
+
+        assert_eq!(req.payment_id().unwrap(), "b7a90176");
+        assert_eq!(req.amount().unwrap().value, 10);
+        assert!(matches!(req.unit().unwrap(), CurrencyUnit::Sat));
+
+        let mints = req.mints().unwrap();
+        assert_eq!(mints.len(), 1);
+        assert_eq!(mints[0], "https://nofees.testnut.cashu.space");
+
+        let transports = req.transports();
+        assert_eq!(transports.len(), 1);
+        assert!(matches!(transports[0].transport_type, TransportType::Nostr));
+    }
+
+    #[test]
+    fn test_roundtrip_payment_request() {
+        let req = PaymentRequest::from_string(PAYMENT_REQUEST.to_string()).unwrap();
+        let encoded = req.to_string_encoded();
+        let decoded = PaymentRequest::from_string(encoded).unwrap();
+
+        assert_eq!(req.payment_id(), decoded.payment_id());
+        assert_eq!(
+            req.amount().map(|a| a.value),
+            decoded.amount().map(|a| a.value)
+        );
+    }
+
+    #[test]
+    fn test_transport_conversion() {
+        let ffi_transport = Transport {
+            transport_type: TransportType::Nostr,
+            target: "nprofile1...".to_string(),
+            tags: Some(vec![vec!["n".to_string(), "17".to_string()]]),
+        };
+
+        let cdk_transport: cdk::nuts::Transport = ffi_transport.clone().into();
+        let back: Transport = cdk_transport.into();
+
+        assert_eq!(ffi_transport.transport_type, back.transport_type);
+        assert_eq!(ffi_transport.target, back.target);
+        assert_eq!(ffi_transport.tags, back.tags);
+    }
+
+    #[test]
+    fn test_create_request_params_default() {
+        let params = CreateRequestParams::default();
+
+        assert_eq!(params.unit, "sat");
+        assert_eq!(params.num_sigs, 1);
+        assert_eq!(params.transport, "none");
+        assert!(params.amount.is_none());
+    }
+
+    #[test]
+    fn test_create_request_params_serialization() {
+        let params = CreateRequestParams {
+            amount: Some(100),
+            unit: "sat".to_string(),
+            description: Some("Test payment".to_string()),
+            transport: "http".to_string(),
+            http_url: Some("https://example.com/callback".to_string()),
+            ..Default::default()
+        };
+
+        let json = encode_create_request_params(params.clone()).unwrap();
+        let decoded = decode_create_request_params(json).unwrap();
+
+        assert_eq!(params.amount, decoded.amount);
+        assert_eq!(params.unit, decoded.unit);
+        assert_eq!(params.description, decoded.description);
+    }
+}

+ 24 - 0
crates/cdk-ffi/src/wallet.rs

@@ -8,6 +8,7 @@ use cdk::wallet::{Wallet as CdkWallet, WalletBuilder as CdkWalletBuilder};
 
 use crate::error::FfiError;
 use crate::token::Token;
+use crate::types::payment_request::PaymentRequest;
 use crate::types::*;
 
 /// FFI-compatible Wallet
@@ -475,6 +476,29 @@ impl Wallet {
             .await?;
         Ok(fee.into())
     }
+
+    /// Pay a NUT-18 payment request
+    ///
+    /// This method prepares and sends a payment for the given payment request.
+    /// It will use the Nostr or HTTP transport specified in the request.
+    ///
+    /// # Arguments
+    ///
+    /// * `payment_request` - The NUT-18 payment request to pay
+    /// * `custom_amount` - Optional amount to pay (required if request has no amount)
+    pub async fn pay_request(
+        &self,
+        payment_request: std::sync::Arc<PaymentRequest>,
+        custom_amount: Option<Amount>,
+    ) -> Result<(), FfiError> {
+        self.inner
+            .pay_request(
+                payment_request.inner().clone(),
+                custom_amount.map(Into::into),
+            )
+            .await?;
+        Ok(())
+    }
 }
 
 /// BIP353 methods for Wallet

+ 1 - 1
crates/cdk-integration-tests/src/init_pure_tests.rs

@@ -258,7 +258,7 @@ pub async fn create_and_start_test_mint() -> Result<Mint> {
 
     let fee_reserve = FeeReserve {
         min_fee_reserve: 1.into(),
-        percent_fee_reserve: 1.0,
+        percent_fee_reserve: 0.02,
     };
 
     let ln_fake_backend = FakeWallet::new(

+ 49 - 4
crates/cdk-integration-tests/src/init_regtest.rs

@@ -2,6 +2,7 @@ use std::env;
 use std::net::Ipv4Addr;
 use std::path::{Path, PathBuf};
 use std::sync::Arc;
+use std::time::Duration;
 
 use anyhow::Result;
 use cdk::types::FeeReserve;
@@ -188,6 +189,50 @@ pub async fn create_lnd_backend(lnd_client: &LndClient) -> Result<CdkLnd> {
     .await?)
 }
 
+/// Wait for an LN client's RPC to be ready, retrying with backoff.
+/// This handles the race condition where the RPC server is still starting up.
+pub async fn wait_for_ln_ready<C>(ln_client: &C) -> Result<()>
+where
+    C: LightningClient,
+{
+    let max_retries = 30;
+    let mut delay = Duration::from_millis(100);
+
+    for attempt in 1..=max_retries {
+        match ln_client.wait_chain_sync().await {
+            Ok(_) => return Ok(()),
+            Err(e) => {
+                let err_str = e.to_string().to_lowercase();
+                // Check if this is a startup/connection error that might resolve
+                // LND: "starting up", "not yet ready" (gRPC)
+                // CLN: "connection refused", "no such file" (Unix socket not ready)
+                let is_startup_error = err_str.contains("starting up")
+                    || err_str.contains("not yet ready")
+                    || err_str.contains("connection refused")
+                    || err_str.contains("no such file")
+                    || err_str.contains("transport error");
+
+                if is_startup_error {
+                    tracing::debug!(
+                        "RPC server not ready yet (attempt {}/{}), retrying in {:?}...",
+                        attempt,
+                        max_retries,
+                        delay
+                    );
+                    tokio::time::sleep(delay).await;
+                    // Exponential backoff, capped at 2 seconds
+                    delay = std::cmp::min(delay * 2, Duration::from_secs(2));
+                } else {
+                    // For other errors, return immediately
+                    return Err(e);
+                }
+            }
+        }
+    }
+
+    anyhow::bail!("RPC server did not become ready after {max_retries} retries")
+}
+
 pub async fn fund_ln<C>(bitcoin_client: &BitcoinClient, ln_client: &C) -> Result<()>
 where
     C: LightningClient,
@@ -261,7 +306,7 @@ pub async fn start_regtest_end(
 
     let cln_client = ClnClient::new(cln_one_dir.clone(), None).await?;
 
-    cln_client.wait_chain_sync().await.unwrap();
+    wait_for_ln_ready(&cln_client).await?;
 
     fund_ln(&bitcoin_client, &cln_client).await.unwrap();
 
@@ -278,7 +323,7 @@ pub async fn start_regtest_end(
 
     let cln_two_client = ClnClient::new(cln_two_dir.clone(), None).await?;
 
-    cln_two_client.wait_chain_sync().await.unwrap();
+    wait_for_ln_ready(&cln_two_client).await?;
 
     fund_ln(&bitcoin_client, &cln_two_client).await.unwrap();
 
@@ -296,7 +341,7 @@ pub async fn start_regtest_end(
     )
     .await?;
 
-    lnd_client.wait_chain_sync().await.unwrap();
+    wait_for_ln_ready(&lnd_client).await?;
 
     if let Some(node) = ldk_node.as_ref() {
         tracing::info!("Starting ldk node");
@@ -327,7 +372,7 @@ pub async fn start_regtest_end(
     )
     .await?;
 
-    lnd_two_client.wait_chain_sync().await.unwrap();
+    wait_for_ln_ready(&lnd_two_client).await?;
 
     fund_ln(&bitcoin_client, &lnd_two_client).await.unwrap();
 

+ 127 - 0
crates/cdk-integration-tests/tests/fake_wallet.rs

@@ -1734,3 +1734,130 @@ async fn test_melt_proofs_external() {
     assert_eq!(transactions.len(), 1);
     assert_eq!(transactions[0].amount, Amount::from(9));
 }
+
+/// Tests that melt automatically performs a swap when proofs don't exactly match
+/// the required amount (quote + fee_reserve + input_fee).
+///
+/// This test verifies the swap-before-melt optimization:
+/// 1. Mint proofs that will NOT exactly match a melt amount
+/// 2. Create a melt quote for a specific amount
+/// 3. Call melt() - it should automatically swap proofs to get exact denominations
+/// 4. Verify the melt succeeded
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_melt_with_swap_for_exact_amount() {
+    let wallet = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create new wallet");
+
+    // Mint 100 sats - this will give us proofs in standard denominations
+    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+
+    let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
+
+    let proofs = proof_streams
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    let initial_balance = wallet.total_balance().await.unwrap();
+    assert_eq!(initial_balance, Amount::from(100));
+
+    // Log the proof denominations we received
+    let proof_amounts: Vec<u64> = proofs.iter().map(|p| u64::from(p.amount)).collect();
+    tracing::info!("Initial proof denominations: {:?}", proof_amounts);
+
+    // Create a melt quote for an amount that likely won't match our proof denominations exactly
+    // Using 7 sats (7000 msats) which requires specific denominations
+    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();
+
+    tracing::info!(
+        "Melt quote: amount={}, fee_reserve={}",
+        melt_quote.amount,
+        melt_quote.fee_reserve
+    );
+
+    // Call melt() - this should trigger swap-before-melt if proofs don't match exactly
+    let melted = wallet.melt(&melt_quote.id).await.unwrap();
+
+    // Verify the melt succeeded
+    assert_eq!(melted.amount, Amount::from(7));
+
+    tracing::info!(
+        "Melt completed: amount={}, fee_paid={}",
+        melted.amount,
+        melted.fee_paid
+    );
+
+    // Verify final balance is correct (initial - melt_amount - fees)
+    let final_balance = wallet.total_balance().await.unwrap();
+    tracing::info!(
+        "Balance: initial={}, final={}, paid={}",
+        initial_balance,
+        final_balance,
+        melted.amount + melted.fee_paid
+    );
+
+    assert!(
+        final_balance < initial_balance,
+        "Balance should have decreased after melt"
+    );
+    assert_eq!(
+        final_balance,
+        initial_balance - melted.amount - melted.fee_paid,
+        "Final balance should be initial - amount - fees"
+    );
+}
+
+/// Tests that melt works correctly when proofs already exactly match the required amount.
+/// In this case, no swap should be needed.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_melt_exact_proofs_no_swap_needed() {
+    let wallet = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create new wallet");
+
+    // Mint a larger amount to have more denomination options
+    let mint_quote = wallet.mint_quote(1000.into(), None).await.unwrap();
+
+    let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
+
+    let _proofs = proof_streams
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    let initial_balance = wallet.total_balance().await.unwrap();
+    assert_eq!(initial_balance, Amount::from(1000));
+
+    // Create a melt for a power-of-2 amount that's more likely to match existing denominations
+    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();
+
+    // Melt should succeed
+    let melted = wallet.melt(&melt_quote.id).await.unwrap();
+
+    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
+    );
+}

+ 498 - 1
crates/cdk-integration-tests/tests/test_swap_flow.rs

@@ -18,6 +18,7 @@ use cashu::{CurrencyUnit, Id, PreMintSecrets, SecretKey, SpendingConditions, Sta
 use cdk::mint::Mint;
 use cdk::nuts::nut00::ProofsMethods;
 use cdk::Amount;
+use cdk_fake_wallet::create_fake_invoice;
 use cdk_integration_tests::init_pure_tests::*;
 
 /// Helper to get the active keyset ID from a mint
@@ -352,6 +353,67 @@ async fn test_swap_unbalanced_transaction_detection() {
     }
 }
 
+/// Tests that swap requests with empty inputs or outputs are rejected:
+/// Case 1: Empty outputs (inputs without outputs)
+/// Case 2: Empty inputs (outputs without inputs)
+/// Both should fail. Currently returns UnitMismatch (11010) instead of
+/// TransactionUnbalanced (11002) because there are no keyset IDs to determine units.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_swap_empty_inputs_or_outputs() {
+    setup_tracing();
+    let mint = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+    let wallet = create_test_wallet_for_mint(mint.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Fund wallet with 100 sats
+    fund_wallet(wallet.clone(), 100, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let proofs = wallet
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    // Case 1: Swap request with inputs but empty outputs
+    // This represents trying to destroy tokens (inputs with no outputs)
+    let swap_request_empty_outputs = SwapRequest::new(proofs.clone(), vec![]);
+
+    match mint.process_swap_request(swap_request_empty_outputs).await {
+        Err(cdk::Error::TransactionUnbalanced(_, _, _)) => {
+            // This would be the more appropriate error
+        }
+        Err(err) => panic!("Wrong error type for empty outputs: {:?}", err),
+        Ok(_) => panic!("Swap with empty outputs should not succeed"),
+    }
+
+    // Case 2: Swap request with empty inputs but with outputs
+    // This represents trying to create tokens from nothing
+    let keyset_id = get_keyset_id(&mint).await;
+    let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
+
+    let preswap = PreMintSecrets::random(
+        keyset_id,
+        100.into(),
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create preswap");
+
+    let swap_request_empty_inputs = SwapRequest::new(vec![], preswap.blinded_messages());
+
+    match mint.process_swap_request(swap_request_empty_inputs).await {
+        Err(cdk::Error::TransactionUnbalanced(_, _, _)) => {
+            // This would be the more appropriate error
+        }
+        Err(err) => panic!("Wrong error type for empty inputs: {:?}", err),
+        Ok(_) => panic!("Swap with empty inputs should not succeed"),
+    }
+}
+
 /// Tests P2PK (Pay-to-Public-Key) spending conditions:
 /// 1. Create proofs locked to a public key
 /// 2. Attempt swap without signature - should fail
@@ -660,7 +722,7 @@ async fn test_swap_with_fees() {
     mint.rotate_keyset(
         CurrencyUnit::Sat,
         cdk_integration_tests::standard_keyset_amounts(32),
-        1,
+        100,
     )
     .await
     .expect("Failed to rotate keyset");
@@ -727,6 +789,441 @@ async fn test_swap_with_fees() {
     }
 }
 
+/// Tests melt with fees enabled and swap-before-melt optimization:
+/// 1. Create mint with keyset that has fees (1000 ppk = 1 sat per proof)
+/// 2. Fund wallet with proofs using default split (optimal denominations)
+/// 3. Call melt() - should automatically swap if proofs don't match exactly
+/// 4. Verify fee calculations are reasonable
+///
+/// Fee calculation:
+/// - Initial: 4096 sats in optimal denominations
+/// - Melt: 1000 sats, fee_reserve = 20 sats (2%)
+/// - inputs_needed = 1020 sats
+/// - Target split for 1020: [512, 256, 128, 64, 32, 16, 8, 4] = 8 proofs
+/// - target_fee = 8 sats
+/// - inputs_total_needed = 1028 sats
+///
+/// The wallet uses two-step selection:
+/// - Step 1: Try to find exact proofs for inputs_needed (no swap fee)
+/// - Step 2: If not exact, select proofs for inputs_total_needed and swap
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_melt_with_fees_swap_before_melt() {
+    setup_tracing();
+    let mint = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+
+    let wallet = create_test_wallet_for_mint(mint.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Rotate to keyset with 1000 ppk = 1 sat per proof fee
+    mint.rotate_keyset(
+        CurrencyUnit::Sat,
+        cdk_integration_tests::standard_keyset_amounts(32),
+        1000, // 1 sat per proof
+    )
+    .await
+    .expect("Failed to rotate keyset");
+
+    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+
+    // Fund with default split target to get optimal denominations
+    // Use larger amount to ensure enough margin for swap fees
+    let initial_amount = 4096u64;
+    fund_wallet(wallet.clone(), initial_amount, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let initial_balance: u64 = wallet.total_balance().await.unwrap().into();
+    assert_eq!(initial_balance, initial_amount);
+
+    let proofs = wallet.get_unspent_proofs().await.unwrap();
+    let proof_amounts: Vec<u64> = proofs.iter().map(|p| u64::from(p.amount)).collect();
+    tracing::info!("Proofs after funding: {:?}", proof_amounts);
+
+    let proofs_total: u64 = proof_amounts.iter().sum();
+    assert_eq!(
+        proofs_total, initial_amount,
+        "Total proofs should equal funded amount"
+    );
+
+    // 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 quote_amount: u64 = melt_quote.amount.into();
+    let fee_reserve: u64 = melt_quote.fee_reserve.into();
+
+    tracing::info!(
+        "Melt quote: amount={}, fee_reserve={}",
+        quote_amount,
+        fee_reserve
+    );
+
+    let initial_proof_count = proofs.len();
+
+    tracing::info!(
+        "Initial state: {} proofs, {} sats",
+        initial_proof_count,
+        proofs_total
+    );
+
+    // Perform melt
+    let melted = wallet.melt(&melt_quote.id).await.unwrap();
+
+    let melt_amount: u64 = melted.amount.into();
+    let ln_fee_paid: u64 = melted.fee_paid.into();
+
+    tracing::info!(
+        "Melt completed: amount={}, ln_fee_paid={}",
+        melt_amount,
+        ln_fee_paid
+    );
+
+    assert_eq!(melt_amount, quote_amount, "Melt amount should match quote");
+
+    // Get final balance and calculate fees
+    let final_balance: u64 = wallet.total_balance().await.unwrap().into();
+    let total_spent = initial_amount - final_balance;
+    let total_fees = total_spent - melt_amount;
+
+    tracing::info!(
+        "Balance: initial={}, final={}, total_spent={}, melt_amount={}, total_fees={}",
+        initial_amount,
+        final_balance,
+        total_spent,
+        melt_amount,
+        total_fees
+    );
+
+    // Calculate input fees (swap + melt)
+    let input_fees = total_fees - ln_fee_paid;
+
+    tracing::info!(
+        "Fee breakdown: total_fees={}, ln_fee={}, input_fees (swap+melt)={}",
+        total_fees,
+        ln_fee_paid,
+        input_fees
+    );
+
+    // Verify input fees are reasonable
+    // With swap-before-melt optimization, we use fewer proofs for the melt
+    // Melt uses ~8 proofs for optimal split of 1028, so input_fee ~= 8
+    // Swap (if any) also has fees, but the optimization minimizes total fees
+    assert!(
+        input_fees > 0,
+        "Should have some input fees with fee-enabled keyset"
+    );
+    assert!(
+        input_fees <= 20,
+        "Input fees {} should be reasonable (not too high)",
+        input_fees
+    );
+
+    // Verify we have change remaining
+    assert!(final_balance > 0, "Should have change remaining after melt");
+
+    tracing::info!(
+        "Test passed: spent {} sats, fees {} (ln={}, input={}), remaining {}",
+        total_spent,
+        total_fees,
+        ln_fee_paid,
+        input_fees,
+        final_balance
+    );
+}
+
+/// Tests the "exact match" early return path in melt_with_metadata.
+/// When proofs already exactly match inputs_needed_amount, no swap is required.
+///
+/// This tests Step 1 of the two-step selection:
+/// - Select proofs for inputs_needed_amount
+/// - If exact match, use proofs directly without swap
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_melt_exact_match_no_swap() {
+    setup_tracing();
+    let mint = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+
+    let wallet = create_test_wallet_for_mint(mint.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Use keyset WITHOUT fees to make exact match easier
+    // (default keyset has no fees)
+
+    // Fund with exactly inputs_needed_amount to trigger the exact match path
+    // For a 1000 sat melt, fee_reserve = max(1, 1000 * 2%) = 20 sats
+    // inputs_needed = 1000 + 20 = 1020 sats
+    let initial_amount = 1020u64;
+    fund_wallet(wallet.clone(), initial_amount, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let initial_balance: u64 = wallet.total_balance().await.unwrap().into();
+    assert_eq!(initial_balance, initial_amount);
+
+    let proofs_before = wallet.get_unspent_proofs().await.unwrap();
+    tracing::info!(
+        "Proofs before melt: {:?}",
+        proofs_before
+            .iter()
+            .map(|p| u64::from(p.amount))
+            .collect::<Vec<_>>()
+    );
+
+    // Create melt quote for 1000 sats
+    // 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 quote_amount: u64 = melt_quote.amount.into();
+    let fee_reserve: u64 = melt_quote.fee_reserve.into();
+    let inputs_needed = quote_amount + fee_reserve;
+
+    tracing::info!(
+        "Melt quote: amount={}, fee_reserve={}, inputs_needed={}",
+        quote_amount,
+        fee_reserve,
+        inputs_needed
+    );
+
+    // Perform melt
+    let melted = wallet.melt(&melt_quote.id).await.unwrap();
+
+    let melt_amount: u64 = melted.amount.into();
+    let ln_fee_paid: u64 = melted.fee_paid.into();
+
+    tracing::info!(
+        "Melt completed: amount={}, ln_fee_paid={}",
+        melt_amount,
+        ln_fee_paid
+    );
+
+    assert_eq!(melt_amount, quote_amount, "Melt amount should match quote");
+
+    // Get final balance
+    let final_balance: u64 = wallet.total_balance().await.unwrap().into();
+    let total_spent = initial_amount - final_balance;
+    let total_fees = total_spent - melt_amount;
+
+    tracing::info!(
+        "Balance: initial={}, final={}, total_spent={}, total_fees={}",
+        initial_amount,
+        final_balance,
+        total_spent,
+        total_fees
+    );
+
+    // With no keyset fees and no swap needed, total fees should just be ln_fee
+    // (no input fees since default keyset has 0 ppk)
+    assert_eq!(
+        total_fees, ln_fee_paid,
+        "Total fees should equal LN fee (no swap or input fees with 0 ppk keyset)"
+    );
+
+    tracing::info!("Test passed: exact match path used, no swap needed");
+}
+
+/// Tests melt with small amounts where swap margin is too tight.
+/// When fees are high relative to the melt amount, the swap-before-melt
+/// optimization may not have enough margin to cover both input and output fees.
+/// In this case, the wallet should fall back to using proofs directly.
+///
+/// Scenario:
+/// - Fund with 8 sats
+/// - Melt 5 sats (with 2% fee_reserve = 1 sat min, so inputs_needed = 6)
+/// - With 1 sat per proof fee, the swap margin becomes too tight
+/// - Should still succeed by falling back to direct melt
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_melt_small_amount_tight_margin() {
+    setup_tracing();
+    let mint = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+
+    let wallet = create_test_wallet_for_mint(mint.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Rotate to keyset with 1000 ppk = 1 sat per proof fee
+    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;
+
+    // Fund with enough to cover melt + fees, but amounts that will trigger swap
+    // 32 sats gives us enough margin even with 1 sat/proof fees
+    let initial_amount = 32;
+    fund_wallet(wallet.clone(), initial_amount, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let initial_balance: u64 = wallet.total_balance().await.unwrap().into();
+    assert_eq!(initial_balance, initial_amount);
+
+    let proofs = wallet.get_unspent_proofs().await.unwrap();
+    tracing::info!(
+        "Proofs after funding: {:?}",
+        proofs
+            .iter()
+            .map(|p| u64::from(p.amount))
+            .collect::<Vec<_>>()
+    );
+
+    // Create melt quote for 5 sats
+    // 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 quote_amount: u64 = melt_quote.amount.into();
+    let fee_reserve: u64 = melt_quote.fee_reserve.into();
+
+    tracing::info!(
+        "Melt quote: amount={}, fee_reserve={}, inputs_needed={}",
+        quote_amount,
+        fee_reserve,
+        quote_amount + fee_reserve
+    );
+
+    // This should succeed even with tight margins
+    let melted = wallet
+        .melt(&melt_quote.id)
+        .await
+        .expect("Melt should succeed even with tight swap margin");
+
+    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,
+        final_balance
+    );
+
+    // Verify balance decreased appropriately
+    assert!(
+        final_balance < initial_balance,
+        "Balance should decrease after melt"
+    );
+}
+
+/// Tests melt where swap proofs barely cover swap_amount + input_fee.
+///
+/// This is a regression test for a bug where the swap-before-melt was called
+/// with include_fees=true, causing it to try to add output fees on top of
+/// swap_amount + input_fee. When proofs_to_swap had just barely enough value,
+/// this caused InsufficientFunds error.
+///
+/// Scenario (from the bug):
+/// - Balance: proofs like [4, 2, 1, 1] = 8 sats
+/// - Melt: 5 sats + 1 fee_reserve = 6 inputs_needed
+/// - target_fee = 1 (for optimal output split)
+/// - inputs_total_needed = 7
+/// - proofs_to_send = [4, 2] = 6, proofs_to_swap = [1, 1] = 2
+/// - swap_amount = 1 sat (7 - 6)
+/// - swap input_fee = 1 sat (2 proofs)
+/// - Before fix: include_fees=true tried to add output fee, causing failure
+/// - After fix: include_fees=false, swap succeeds
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_melt_swap_tight_margin_regression() {
+    setup_tracing();
+    let mint = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+
+    let wallet = create_test_wallet_for_mint(mint.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Rotate to keyset with 250 ppk = 0.25 sat per proof fee (same as original bug scenario)
+    // This means 4 proofs = 1 sat fee
+    mint.rotate_keyset(
+        CurrencyUnit::Sat,
+        cdk_integration_tests::standard_keyset_amounts(32),
+        250,
+    )
+    .await
+    .expect("Failed to rotate keyset");
+
+    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+
+    // Fund with 100 sats using default split to get optimal denominations
+    // This should give us proofs like [64, 32, 4] or similar power-of-2 split
+    let initial_amount = 100;
+    fund_wallet(wallet.clone(), initial_amount, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let initial_balance: u64 = wallet.total_balance().await.unwrap().into();
+    assert_eq!(initial_balance, initial_amount);
+
+    let proofs = wallet.get_unspent_proofs().await.unwrap();
+    let proof_amounts: Vec<u64> = proofs.iter().map(|p| u64::from(p.amount)).collect();
+    tracing::info!("Proofs after funding: {:?}", proof_amounts);
+
+    // Create melt quote for 5 sats (5000 msats)
+    // fee_reserve = max(1, 5 * 2%) = 1 sat
+    // inputs_needed = 5 + 1 = 6 sats
+    // The optimal split for 6 sats is [4, 2] (2 proofs)
+    // target_fee = 1 sat (2 proofs * 0.25, rounded up)
+    // inputs_total_needed = 7 sats
+    //
+    // If we don't have exact [4, 2] proofs, we'll need to swap.
+    // 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 quote_amount: u64 = melt_quote.amount.into();
+    let fee_reserve: u64 = melt_quote.fee_reserve.into();
+
+    tracing::info!(
+        "Melt quote: amount={}, fee_reserve={}, inputs_needed={}",
+        quote_amount,
+        fee_reserve,
+        quote_amount + fee_reserve
+    );
+
+    // 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)
+        .await
+        .expect("Melt should succeed with swap-before-melt (regression test)");
+
+    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,
+        final_balance
+    );
+
+    // Should have change remaining
+    assert!(
+        final_balance < initial_balance,
+        "Balance should decrease after melt"
+    );
+    assert!(final_balance > 0, "Should have change remaining");
+}
+
 /// Tests that swap correctly handles amount overflow:
 /// Attempts to create outputs that would overflow u64 when summed.
 /// This should be rejected before any database operations occur.

+ 33 - 0
crates/cdk-sql-common/src/mint/mod.rs

@@ -258,6 +258,39 @@ where
         .await?;
 
         if total_deleted != ys.len() {
+            // Query current states to provide detailed logging
+            let current_states = get_current_states(&self.inner, ys).await?;
+
+            let missing_count = ys.len() - current_states.len();
+            let spent_count = current_states
+                .values()
+                .filter(|s| **s == State::Spent)
+                .count();
+
+            if missing_count > 0 {
+                tracing::warn!(
+                    "remove_proofs: {} of {} proofs do not exist in database (already removed?)",
+                    missing_count,
+                    ys.len()
+                );
+            }
+
+            if spent_count > 0 {
+                tracing::warn!(
+                    "remove_proofs: {} of {} proofs are in Spent state and cannot be removed",
+                    spent_count,
+                    ys.len()
+                );
+            }
+
+            tracing::debug!(
+                "remove_proofs details: requested={}, deleted={}, missing={}, spent={}",
+                ys.len(),
+                total_deleted,
+                missing_count,
+                spent_count
+            );
+
             return Err(Self::Err::AttemptRemoveSpentProof);
         }
 

+ 4 - 0
crates/cdk/Cargo.toml

@@ -143,6 +143,10 @@ name = "human_readable_payment"
 required-features = ["wallet", "bip353"]
 
 [[example]]
+name = "payment_request"
+required-features = ["wallet", "nostr"]
+
+[[example]]
 name = "token-proofs"
 required-features = ["wallet"]
 

+ 252 - 0
crates/cdk/examples/payment_request.rs

@@ -0,0 +1,252 @@
+//! # Payment Request Example (NUT-18)
+//!
+//! This example demonstrates how to create and receive payments using NUT-18
+//! payment requests with the MultiMintWallet. It shows both HTTP and Nostr
+//! transport options.
+//!
+//! ## Payment Request Flow
+//!
+//! 1. Receiver creates a payment request with desired parameters
+//! 2. Receiver shares the encoded payment request string with the payer
+//! 3. Payer decodes the request and sends tokens via the specified transport
+//! 4. Receiver waits for and receives the payment
+//!
+//! ## Transport Options
+//!
+//! - **Nostr**: Privacy-preserving delivery via Nostr relays (gift-wrapped events)
+//! - **HTTP**: Direct delivery to a specified callback URL
+//! - **None**: Out-of-band delivery (receiver must receive tokens manually)
+//!
+//! ## Usage
+//!
+//! ```bash
+//! cargo run --example payment_request --features="wallet nostr"
+//! ```
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use anyhow::anyhow;
+use cdk::amount::SplitTarget;
+use cdk::nuts::CurrencyUnit;
+use cdk::wallet::multi_mint_wallet::MultiMintWallet;
+use cdk::wallet::payment_request::CreateRequestParams;
+use cdk_sqlite::wallet::memory;
+use rand::random;
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+    println!("NUT-18 Payment Request Example");
+    println!("===============================\n");
+
+    // Generate a random seed for the wallet
+    let seed: [u8; 64] = random();
+
+    // Mint URL and currency unit
+    let mint_url = "https://fake.thesimplekid.dev";
+    let unit = CurrencyUnit::Sat;
+    let initial_amount = cdk::Amount::from(100);
+
+    // Initialize the memory store
+    let localstore = Arc::new(memory::empty().await?);
+
+    // Create a new MultiMintWallet
+    let wallet = MultiMintWallet::new(localstore, seed, unit.clone()).await?;
+
+    // Add the mint to our wallet
+    wallet.add_mint(mint_url.parse()?).await?;
+
+    println!("Step 1: Funding the wallet");
+    println!("---------------------------");
+
+    // Get a wallet for our mint to create a mint quote
+    let mint_wallet = wallet
+        .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?;
+
+    println!(
+        "Pay this invoice to fund the wallet:\n{}",
+        mint_quote.request
+    );
+    println!("\nQuote ID: {}", mint_quote.id);
+
+    // Wait for payment and mint tokens
+    println!("\nWaiting for payment...");
+    let _proofs = mint_wallet
+        .wait_and_mint_quote(
+            mint_quote,
+            SplitTarget::default(),
+            None,
+            Duration::from_secs(300),
+        )
+        .await?;
+
+    let balance = wallet.total_balance().await?;
+    println!("Wallet funded with {} sats\n", balance);
+
+    // ============================================================================
+    // Example 1: Create a Payment Request with Nostr Transport
+    // ============================================================================
+
+    println!("\n╔════════════════════════════════════════════════════════════════╗");
+    println!("║ Example 1: Payment Request with Nostr Transport               ║");
+    println!("╚════════════════════════════════════════════════════════════════╝\n");
+
+    println!("Creating a payment request for 10 sats via Nostr...\n");
+
+    let nostr_params = CreateRequestParams {
+        amount: Some(10),
+        unit: "sat".to_string(),
+        description: Some("Coffee payment".to_string()),
+        pubkeys: None,
+        num_sigs: 1,
+        hash: None,
+        preimage: None,
+        transport: "nostr".to_string(),
+        http_url: None,
+        nostr_relays: Some(vec![
+            "wss://relay.damus.io".to_string(),
+            "wss://nos.lol".to_string(),
+        ]),
+    };
+
+    let (payment_request, nostr_wait_info) = wallet.create_request(nostr_params).await?;
+
+    println!("Payment Request Created!");
+    println!("------------------------");
+    println!("Encoded: {}\n", payment_request);
+
+    println!("Request Details:");
+    println!("  Amount: {:?}", payment_request.amount);
+    println!("  Unit: {:?}", payment_request.unit);
+    println!("  Description: {:?}", payment_request.description);
+    println!("  Mints: {:?}", payment_request.mints);
+    println!("  Transports: {:?}", payment_request.transports);
+
+    if let Some(ref info) = nostr_wait_info {
+        println!("\nNostr Wait Info:");
+        println!("  Relays: {:?}", info.relays);
+        println!("  Pubkey: {}", info.pubkey);
+
+        println!("\nTo receive payment, call:");
+        println!("  let amount = wallet.wait_for_nostr_payment(nostr_wait_info).await?;");
+        println!("\nThis will:");
+        println!("  1. Connect to the specified Nostr relays");
+        println!("  2. Subscribe for gift-wrapped payment events");
+        println!("  3. Receive and process the first valid payment");
+        println!("  4. Return the received amount");
+
+        // Uncomment to actually wait for a payment:
+        // println!("\nWaiting for Nostr payment...");
+        // let received = wallet.wait_for_nostr_payment(info.clone()).await?;
+        // println!("Received {} sats via Nostr!", received);
+    }
+
+    // ============================================================================
+    // Example 2: Create a Payment Request with HTTP Transport
+    // ============================================================================
+
+    println!("\n\n╔════════════════════════════════════════════════════════════════╗");
+    println!("║ Example 2: Payment Request with HTTP Transport                ║");
+    println!("╚════════════════════════════════════════════════════════════════╝\n");
+
+    println!("Creating a payment request for 21 sats via HTTP...\n");
+
+    let http_params = CreateRequestParams {
+        amount: Some(21),
+        unit: "sat".to_string(),
+        description: Some("Tip jar".to_string()),
+        pubkeys: None,
+        num_sigs: 1,
+        hash: None,
+        preimage: None,
+        transport: "http".to_string(),
+        http_url: Some("https://example.com/cashu/callback".to_string()),
+        nostr_relays: None,
+    };
+
+    let (http_request, _) = wallet.create_request(http_params).await?;
+
+    println!("Payment Request Created!");
+    println!("------------------------");
+    println!("Encoded: {}\n", http_request);
+
+    println!("Request Details:");
+    println!("  Amount: {:?}", http_request.amount);
+    println!("  Unit: {:?}", http_request.unit);
+    println!("  Description: {:?}", http_request.description);
+    println!("  Transports: {:?}", http_request.transports);
+
+    println!("\nWith HTTP transport:");
+    println!("  - Payer will POST tokens to: https://example.com/cashu/callback");
+    println!("  - Your server receives the token and calls wallet.receive()");
+
+    // ============================================================================
+    // Example 3: Create a Payment Request with P2PK Spending Conditions
+    // ============================================================================
+
+    println!("\n\n╔════════════════════════════════════════════════════════════════╗");
+    println!("║ Example 3: Payment Request with P2PK Lock                     ║");
+    println!("╚════════════════════════════════════════════════════════════════╝\n");
+
+    println!("Creating a P2PK-locked payment request...\n");
+
+    // Generate a secret key for the spending condition
+    let secret = cdk::nuts::SecretKey::generate();
+    let pubkey_hex = secret.public_key().to_string();
+
+    let p2pk_params = CreateRequestParams {
+        amount: Some(50),
+        unit: "sat".to_string(),
+        description: Some("Locked payment".to_string()),
+        pubkeys: Some(vec![pubkey_hex.clone()]),
+        num_sigs: 1,
+        hash: None,
+        preimage: None,
+        transport: "nostr".to_string(),
+        http_url: None,
+        nostr_relays: Some(vec!["wss://relay.damus.io".to_string()]),
+    };
+
+    let (p2pk_request, _) = wallet.create_request(p2pk_params).await?;
+
+    println!("P2PK Payment Request Created!");
+    println!("-----------------------------");
+    println!("Encoded: {}\n", p2pk_request);
+
+    println!("Security:");
+    println!("  - Tokens sent to this request will be locked to pubkey:");
+    println!("    {}", pubkey_hex);
+    println!("  - Only the holder of the corresponding secret key can spend");
+
+    // ============================================================================
+    // Example 4: Paying a Payment Request
+    // ============================================================================
+
+    println!("\n\n╔════════════════════════════════════════════════════════════════╗");
+    println!("║ Example 4: Paying a Payment Request                           ║");
+    println!("╚════════════════════════════════════════════════════════════════╝\n");
+
+    println!("To pay a payment request from another wallet:\n");
+
+    println!("```rust");
+    println!("// Decode the payment request");
+    println!("let request = PaymentRequest::from_str(\"creqA...\")?;");
+    println!();
+    println!("// Pay the request (sends tokens via the specified transport)");
+    println!("let result = wallet.pay_request(request).await?;");
+    println!();
+    println!("println!(\"Sent {{}} sats\", result.amount_sent);");
+    println!("```\n");
+
+    println!("The pay_request method will:");
+    println!("  1. Select proofs matching the requested amount and unit");
+    println!("  2. Apply any spending conditions from the request");
+    println!("  3. Deliver the token via the request's transport (Nostr/HTTP)");
+
+    println!("\n✓ Example complete!");
+
+    Ok(())
+}

+ 8 - 2
crates/cdk/src/mint/melt/melt_saga/compensation.rs

@@ -7,6 +7,7 @@ use async_trait::async_trait;
 use cdk_common::database::DynMintDatabase;
 use cdk_common::{Error, PublicKey, QuoteId};
 use tracing::instrument;
+use uuid::Uuid;
 
 /// Trait for compensating actions in the saga pattern.
 ///
@@ -25,6 +26,7 @@ pub trait CompensatingAction: Send + Sync {
 /// - Input proofs (identified by input_ys)
 /// - Output blinded messages (identified by blinded_secrets)
 /// - Melt request tracking record
+/// - Saga state record
 ///
 ///   And resets:
 /// - Quote state from Pending back to Unpaid
@@ -37,6 +39,8 @@ pub struct RemoveMeltSetup {
     pub blinded_secrets: Vec<PublicKey>,
     /// Quote ID to reset state
     pub quote_id: QuoteId,
+    /// Operation ID (saga ID) to delete
+    pub operation_id: Uuid,
 }
 
 #[async_trait]
@@ -44,10 +48,11 @@ impl CompensatingAction for RemoveMeltSetup {
     #[instrument(skip_all)]
     async fn execute(&self, db: &DynMintDatabase) -> Result<(), Error> {
         tracing::info!(
-            "Compensation: Removing melt setup for quote {} ({} proofs, {} blinded messages)",
+            "Compensation: Removing melt setup for quote {} ({} proofs, {} blinded messages, saga {})",
             self.quote_id,
             self.input_ys.len(),
-            self.blinded_secrets.len()
+            self.blinded_secrets.len(),
+            self.operation_id
         );
 
         super::super::shared::rollback_melt_quote(
@@ -55,6 +60,7 @@ impl CompensatingAction for RemoveMeltSetup {
             &self.quote_id,
             &self.input_ys,
             &self.blinded_secrets,
+            &self.operation_id,
         )
         .await
     }

+ 1 - 0
crates/cdk/src/mint/melt/melt_saga/mod.rs

@@ -385,6 +385,7 @@ impl MeltSaga<Initial> {
                 input_ys: input_ys.clone(),
                 blinded_secrets,
                 quote_id: quote.id.clone(),
+                operation_id: *self.operation.id(),
             }));
 
         // Transition to SetupComplete state

+ 101 - 0
crates/cdk/src/mint/melt/melt_saga/tests.rs

@@ -1012,6 +1012,107 @@ async fn test_compensation_idempotent() {
     // SUCCESS: Compensation is idempotent and safe to run multiple times!
 }
 
+/// Test: Saga is deleted after direct payment failure (not via recovery)
+///
+/// This test validates that when make_payment() fails and compensate_all()
+/// is called directly during normal operation (not through crash recovery),
+/// the saga is properly deleted from the database.
+///
+/// This is different from the recovery tests which simulate crashes - this
+/// tests the actual payment failure path in normal operation.
+#[tokio::test]
+async fn test_saga_deleted_after_payment_failure() {
+    use cdk_common::CurrencyUnit;
+    use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription};
+
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let input_ys = proofs.ys().unwrap();
+
+    // STEP 2: Create a quote that will FAIL payment
+    let fake_description = FakeInvoiceDescription {
+        pay_invoice_state: MeltQuoteState::Failed, // Payment will fail
+        check_payment_state: MeltQuoteState::Failed, // Check will also show failed
+        pay_err: false,
+        check_err: false,
+    };
+
+    let amount_msats: u64 = Amount::from(9_000).into();
+    let invoice = create_fake_invoice(
+        amount_msats,
+        serde_json::to_string(&fake_description).unwrap(),
+    );
+
+    let bolt11_request = cdk_common::nuts::MeltQuoteBolt11Request {
+        request: invoice,
+        unit: CurrencyUnit::Sat,
+        options: None,
+    };
+
+    let request = cdk_common::melt::MeltQuoteRequest::Bolt11(bolt11_request);
+    let quote_response = mint.get_melt_quote(request).await.unwrap();
+    let quote = mint
+        .localstore
+        .get_melt_quote(&quote_response.quote)
+        .await
+        .unwrap()
+        .expect("Quote should exist");
+
+    let melt_request = create_test_melt_request(&proofs, &quote);
+
+    // STEP 3: Setup melt saga
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+    let operation_id = *setup_saga.operation.id();
+
+    // Verify saga exists after setup
+    assert_saga_exists(&mint, &operation_id).await;
+    assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
+
+    // STEP 4: Attempt internal settlement (will return RequiresExternalPayment)
+    let (payment_saga, decision) = setup_saga
+        .attempt_internal_settlement(&melt_request)
+        .await
+        .unwrap();
+
+    // STEP 5: Make payment - this should FAIL and trigger compensate_all()
+    let result = payment_saga.make_payment(decision).await;
+
+    // Payment should fail
+    assert!(
+        result.is_err(),
+        "Payment should fail with Failed status from FakeWallet"
+    );
+
+    // STEP 6: Verify saga was deleted after compensation
+    // This is the key assertion - the saga should be cleaned up after compensate_all()
+    assert_saga_not_exists(&mint, &operation_id).await;
+
+    // STEP 7: Verify proofs were returned (removed from database)
+    assert_proofs_state(&mint, &input_ys, None).await;
+
+    // STEP 8: Verify quote state was reset to Unpaid
+    let recovered_quote = mint
+        .localstore
+        .get_melt_quote(&quote.id)
+        .await
+        .unwrap()
+        .expect("Quote should still exist");
+    assert_eq!(
+        recovered_quote.state,
+        MeltQuoteState::Unpaid,
+        "Quote state should be reset to Unpaid after compensation"
+    );
+
+    // SUCCESS: Saga properly deleted after direct payment failure!
+}
+
 // ============================================================================
 // Saga Content Validation Tests
 // ============================================================================

+ 19 - 3
crates/cdk/src/mint/melt/shared.rs

@@ -76,16 +76,18 @@ pub async fn rollback_melt_quote(
     quote_id: &QuoteId,
     input_ys: &[PublicKey],
     blinded_secrets: &[PublicKey],
+    operation_id: &uuid::Uuid,
 ) -> Result<(), Error> {
     if input_ys.is_empty() && blinded_secrets.is_empty() {
         return Ok(());
     }
 
     tracing::info!(
-        "Rolling back melt quote {} ({} proofs, {} blinded messages)",
+        "Rolling back melt quote {} ({} proofs, {} blinded messages, saga {})",
         quote_id,
         input_ys.len(),
-        blinded_secrets.len()
+        blinded_secrets.len(),
+        operation_id
     );
 
     let mut tx = db.begin_transaction().await?;
@@ -115,9 +117,23 @@ pub async fn rollback_melt_quote(
     // Delete melt request tracking record
     tx.delete_melt_request(quote_id).await?;
 
+    // Delete saga state record
+    if let Err(e) = tx.delete_saga(operation_id).await {
+        tracing::warn!(
+            "Failed to delete saga {} during rollback: {}",
+            operation_id,
+            e
+        );
+        // Continue anyway - saga cleanup is best-effort
+    }
+
     tx.commit().await?;
 
-    tracing::info!("Successfully rolled back melt quote {}", quote_id);
+    tracing::info!(
+        "Successfully rolled back melt quote {} and deleted saga {}",
+        quote_id,
+        operation_id
+    );
 
     Ok(())
 }

+ 3 - 10
crates/cdk/src/mint/start_up_check.rs

@@ -126,12 +126,14 @@ impl Mint {
             );
 
             // Use the same compensation logic as in-process failures
+            // Saga deletion is included in the compensation transaction
             let compensation = RemoveSwapSetup {
                 blinded_secrets: saga.blinded_secrets.clone(),
                 input_ys: saga.input_ys.clone(),
+                operation_id: saga.operation_id,
             };
 
-            // Execute compensation
+            // Execute compensation (includes saga deletion)
             if let Err(e) = compensation.execute(&self.localstore).await {
                 tracing::error!(
                     "Failed to compensate saga {}: {}. Continuing...",
@@ -141,15 +143,6 @@ impl Mint {
                 continue;
             }
 
-            // Delete saga after successful compensation
-            let mut tx = self.localstore.begin_transaction().await?;
-            if let Err(e) = tx.delete_saga(&saga.operation_id).await {
-                tracing::error!("Failed to delete saga for {}: {}", saga.operation_id, e);
-                tx.rollback().await?;
-                continue;
-            }
-            tx.commit().await?;
-
             tracing::info!("Successfully recovered saga {}", saga.operation_id);
         }
 

+ 17 - 10
crates/cdk/src/mint/swap/mod.rs

@@ -29,20 +29,27 @@ impl Mint {
         // and HTLC (including SIGALL)
         swap_request.verify_spending_conditions()?;
 
+        let input_proofs = swap_request.inputs();
+
+        if input_proofs.is_empty() {
+            return Err(Error::TransactionUnbalanced(
+                0,
+                swap_request.output_amount()?.to_u64(),
+                0,
+            ));
+        }
+
         // We don't need to check P2PK or HTLC again. It has all been checked above
         // and the code doesn't reach here unless such verifications were satisfactory
 
         // Verify inputs (cryptographic verification, no DB needed)
-        let input_verification =
-            self.verify_inputs(swap_request.inputs())
-                .await
-                .map_err(|err| {
-                    #[cfg(feature = "prometheus")]
-                    self.record_swap_failure("process_swap_request");
-
-                    tracing::debug!("Input verification failed: {:?}", err);
-                    err
-                })?;
+        let input_verification = self.verify_inputs(input_proofs).await.map_err(|err| {
+            #[cfg(feature = "prometheus")]
+            self.record_swap_failure("process_swap_request");
+
+            tracing::debug!("Input verification failed: {:?}", err);
+            err
+        })?;
 
         // Step 1: Initialize the swap saga
         let init_saga = SwapSaga::new(self, self.localstore.clone(), self.pubsub_manager.clone());

+ 22 - 2
crates/cdk/src/mint/swap/swap_saga/compensation.rs

@@ -2,6 +2,7 @@ use async_trait::async_trait;
 use cdk_common::database::DynMintDatabase;
 use cdk_common::{Error, PublicKey};
 use tracing::instrument;
+use uuid::Uuid;
 
 #[async_trait]
 pub trait CompensatingAction: Send + Sync {
@@ -15,6 +16,7 @@ pub trait CompensatingAction: Send + Sync {
 /// the setup transaction has committed. It removes:
 /// - Output blinded messages (identified by blinded_secrets)
 /// - Input proofs (identified by input_ys)
+/// - Saga state record
 ///
 /// This restores the database to its pre-swap state.
 pub struct RemoveSwapSetup {
@@ -22,6 +24,8 @@ pub struct RemoveSwapSetup {
     pub blinded_secrets: Vec<PublicKey>,
     /// Y values (public keys) from the input proofs
     pub input_ys: Vec<PublicKey>,
+    /// Operation ID (saga ID) to delete
+    pub operation_id: Uuid,
 }
 
 #[async_trait]
@@ -33,9 +37,10 @@ impl CompensatingAction for RemoveSwapSetup {
         }
 
         tracing::info!(
-            "Compensation: Removing swap setup ({} blinded messages, {} proofs)",
+            "Compensation: Removing swap setup ({} blinded messages, {} proofs, saga {})",
             self.blinded_secrets.len(),
-            self.input_ys.len()
+            self.input_ys.len(),
+            self.operation_id
         );
 
         let mut tx = db.begin_transaction().await?;
@@ -50,8 +55,23 @@ impl CompensatingAction for RemoveSwapSetup {
             tx.remove_proofs(&self.input_ys, None).await?;
         }
 
+        // Delete saga state record
+        if let Err(e) = tx.delete_saga(&self.operation_id).await {
+            tracing::warn!(
+                "Failed to delete saga {} during compensation: {}",
+                self.operation_id,
+                e
+            );
+            // Continue anyway - saga cleanup is best-effort
+        }
+
         tx.commit().await?;
 
+        tracing::info!(
+            "Successfully compensated swap and deleted saga {}",
+            self.operation_id
+        );
+
         Ok(())
     }
 

+ 1 - 21
crates/cdk/src/mint/swap/swap_saga/mod.rs

@@ -256,6 +256,7 @@ impl<'a> SwapSaga<'a, Initial> {
             .push_front(Box::new(RemoveSwapSetup {
                 blinded_secrets: blinded_secrets.clone(),
                 input_ys: ys.clone(),
+                operation_id: *self.operation.id(),
             }));
 
         // Transition to SetupComplete state
@@ -488,27 +489,6 @@ impl<S> SwapSaga<'_, S> {
             }
         }
 
-        // Delete saga - swap was compensated
-        // Use a separate transaction since compensations already ran
-        // Don't fail the compensation if saga cleanup fails (log only)
-        let mut tx = match self.db.begin_transaction().await {
-            Ok(tx) => tx,
-            Err(e) => {
-                tracing::error!(
-                    "Failed to begin tx for saga cleanup after compensation: {}",
-                    e
-                );
-                return Ok(()); // Compensations already ran, don't fail now
-            }
-        };
-
-        if let Err(e) = tx.delete_saga(self.operation.id()).await {
-            tracing::warn!("Failed to delete saga after compensation: {}", e);
-        } else if let Err(e) = tx.commit().await {
-            tracing::error!("Failed to commit saga cleanup after compensation: {}", e);
-        }
-        // Always succeed - compensations are done, saga cleanup is best-effort
-
         Ok(())
     }
 }

+ 19 - 3
crates/cdk/src/mint/verification.rs

@@ -227,7 +227,25 @@ impl Mint {
             err
         })?;
 
-        if output_verification.unit != input_verification.unit {
+        let fees = self.get_proofs_fee(inputs).await?;
+
+        if output_verification
+            .unit
+            .as_ref()
+            .ok_or(Error::TransactionUnbalanced(
+                input_verification.amount.to_u64(),
+                output_verification.amount.to_u64(),
+                fees.into(),
+            ))?
+            != input_verification
+                .unit
+                .as_ref()
+                .ok_or(Error::TransactionUnbalanced(
+                    input_verification.amount.to_u64(),
+                    output_verification.amount.to_u64(),
+                    0,
+                ))?
+        {
             tracing::debug!(
                 "Output unit {:?} does not match input unit {:?}",
                 output_verification.unit,
@@ -236,8 +254,6 @@ impl Mint {
             return Err(Error::UnitMismatch);
         }
 
-        let fees = self.get_proofs_fee(inputs).await?;
-
         if output_verification.amount
             != input_verification
                 .amount

+ 113 - 8
crates/cdk/src/wallet/melt/melt_bolt11.rs

@@ -1,6 +1,7 @@
 use std::collections::HashMap;
 use std::str::FromStr;
 
+use cdk_common::amount::SplitTarget;
 use cdk_common::wallet::{Transaction, TransactionDirection};
 use cdk_common::PaymentMethod;
 use lightning_invoice::Bolt11Invoice;
@@ -8,12 +9,14 @@ use tracing::instrument;
 
 use crate::amount::to_unit;
 use crate::dhke::construct_proofs;
+use crate::nuts::nut00::ProofsMethods;
 use crate::nuts::{
     CurrencyUnit, MeltOptions, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltRequest,
-    PreMintSecrets, Proofs, ProofsMethods, State,
+    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};
 
@@ -170,7 +173,10 @@ impl Wallet {
 
         tx.update_proofs(proofs_info, vec![]).await?;
 
-        let change_amount = proofs_total - quote_info.amount;
+        // 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?;
+        let change_amount = proofs_total - quote_info.amount - input_fee;
 
         let premint_secrets = if change_amount <= Amount::ZERO {
             PreMintSecrets::new(active_keyset_id)
@@ -395,25 +401,124 @@ impl Wallet {
 
         let inputs_needed_amount = quote_info.amount + quote_info.fee_reserve;
 
-        let available_proofs = self.get_unspent_proofs().await?;
-
         let active_keyset_ids = self
             .get_mint_keysets()
             .await?
             .into_iter()
             .map(|k| k.id)
             .collect();
-        let keyset_fees = self.get_keyset_fees_and_amounts().await?;
+        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?;
+
+        // 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_needed_amount,
+            inputs_total_needed,
             available_proofs,
             &active_keyset_ids,
-            &keyset_fees,
+            &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, input_proofs, metadata)
+        self.melt_proofs_with_metadata(quote_id, final_proofs, metadata)
             .await
     }
 }

+ 15 - 1
crates/cdk/src/wallet/multi_mint_wallet.rs

@@ -25,7 +25,7 @@ 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, Token};
+use crate::nuts::{CurrencyUnit, MeltOptions, Proof, Proofs, SpendingConditions, State, Token};
 use crate::types::Melted;
 #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
 use crate::wallet::mint_connector::transport::tor_transport::TorAsync;
@@ -616,6 +616,20 @@ impl MultiMintWallet {
         Ok(mint_proofs)
     }
 
+    /// NUT-07 Check the state of proofs with a specific mint
+    #[instrument(skip(self, proofs))]
+    pub async fn check_proofs_state(
+        &self,
+        mint_url: &MintUrl,
+        proofs: Proofs,
+    ) -> Result<Vec<State>, Error> {
+        let wallet = self.get_wallet(mint_url).await.ok_or(Error::UnknownMint {
+            mint_url: mint_url.to_string(),
+        })?;
+        let states = wallet.check_proofs_spent(proofs).await?;
+        Ok(states.into_iter().map(|s| s.state).collect())
+    }
+
     /// List transactions
     #[instrument(skip(self))]
     pub async fn list_transactions(

+ 3 - 3
crates/cdk/src/wallet/reclaim.rs

@@ -73,7 +73,7 @@ impl Wallet {
 
     /// Perform an async task, which is assumed to be a foreign mint call that can fail. If fails,
     /// the proofs used in the request are synchronize with the mint and update it locally
-    #[inline(always)]
+    #[inline]
     pub(crate) fn try_proof_operation_or_reclaim<'a, F, R>(
         &'a self,
         inputs: Proofs,
@@ -105,13 +105,13 @@ impl Wallet {
 
                     if swap_reverted_proofs {
                         tracing::error!(
-                            "Attempting to swap exposed {} proofs to new proofs",
+                            "Checking proofs state for proofs {} used in failed op",
                             inputs.len()
                         );
                         for proofs in inputs.chunks(BATCH_PROOF_SIZE) {
                             let _ = self.sync_proofs_state(proofs.to_owned()).await.inspect_err(
                                 |err| {
-                                    tracing::warn!("Failed to swap exposed proofs ({})", err);
+                                    tracing::warn!("Failed to check exposed proofs ({})", err);
                                 },
                             );
                         }

+ 186 - 0
crates/cdk/src/wallet/send.rs

@@ -524,6 +524,7 @@ pub struct ProofSplitResult {
 /// * `keyset_fees` - Map of keyset ID to fee_ppk
 /// * `force_swap` - If true, all proofs go to swap
 /// * `is_exact_or_offline` - If true (exact match or offline mode), all proofs go to send
+// TODO: Consider making this pub(crate) - this function is also used by melt operations
 pub fn split_proofs_for_send(
     proofs: Proofs,
     send_amounts: &[Amount],
@@ -1704,4 +1705,189 @@ mod tests {
         // Should handle this gracefully
         assert!(result.proofs_to_send.len() + result.proofs_to_swap.len() == 22);
     }
+
+    // ========================================================================
+    // Melt Use Case Tests
+    // For melt: amount = inputs_needed (quote + fee_reserve),
+    //           send_fee = target_fee (input fee for target proofs)
+    // ========================================================================
+
+    #[test]
+    fn test_melt_exact_proofs_no_swap() {
+        // Melt scenario: have exact proofs matching target denominations
+        // quote_amount + fee_reserve = 100, target_fee = 2
+        // Need proofs totaling 102
+        let input_proofs = proofs(&[64, 32, 4, 2]);
+        let target_amounts = amounts(&[64, 32, 4, 2]); // split of 102
+        let keyset_fees = keyset_fees_with_ppk(500); // 0.5 sat per proof
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &target_amounts,
+            Amount::from(100), // inputs_needed_amount
+            Amount::from(2),   // target_fee (4 proofs * 0.5)
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // All proofs match, no swap needed
+        assert_eq!(result.proofs_to_send.len(), 4);
+        assert!(result.proofs_to_swap.is_empty());
+        assert_eq!(result.swap_fee, Amount::ZERO);
+    }
+
+    #[test]
+    fn test_melt_excess_proofs_needs_swap() {
+        // Melt scenario: have proofs totaling more than needed
+        // Need 102 (100 + 2 fee), but have 128
+        let input_proofs = proofs(&[128]);
+        let target_amounts = amounts(&[64, 32, 4, 2]); // optimal split of 102
+        let keyset_fees = keyset_fees_with_ppk(500);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &target_amounts,
+            Amount::from(100),
+            Amount::from(2),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // 128 doesn't match any target, needs swap
+        assert!(result.proofs_to_send.is_empty());
+        assert_eq!(result.proofs_to_swap.len(), 1);
+        assert_eq!(result.proofs_to_swap[0].amount, Amount::from(128));
+    }
+
+    #[test]
+    fn test_melt_partial_match_with_swap() {
+        // Melt scenario: some proofs match, others need swap
+        // Need 100 + 2 fee = 102, have [64, 32, 16, 8] = 120
+        let input_proofs = proofs(&[64, 32, 16, 8]);
+        let target_amounts = amounts(&[64, 32, 4, 2]); // optimal split of 102
+        let keyset_fees = keyset_fees_with_ppk(500);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &target_amounts,
+            Amount::from(100),
+            Amount::from(2),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // 64 and 32 match, 16 and 8 go to swap
+        let send_amounts: Vec<u64> = result
+            .proofs_to_send
+            .iter()
+            .map(|p| p.amount.into())
+            .collect();
+        assert!(send_amounts.contains(&64));
+        assert!(send_amounts.contains(&32));
+
+        // 16 and 8 should be in swap to produce the remaining 6 (4+2)
+        assert!(!result.proofs_to_swap.is_empty());
+    }
+
+    #[test]
+    fn test_melt_with_exact_target_match() {
+        // Melt scenario: all target amounts match input proofs exactly
+        // When all targets are matched, unneeded proofs are dropped (not swapped)
+        let input_proofs = proofs(&[64, 32, 8, 4, 2]);
+        let target_amounts = amounts(&[64, 32, 8, 4, 2]); // exact match
+        let keyset_fees = keyset_fees_with_ppk(1000);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &target_amounts,
+            Amount::from(105), // amount
+            Amount::from(5),   // target fee
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // All proofs match target amounts
+        assert_eq!(result.proofs_to_send.len(), 5);
+        // No swap needed when all targets matched
+        assert!(result.proofs_to_swap.is_empty());
+    }
+
+    #[test]
+    fn test_melt_swap_fee_calculated() {
+        // Verify swap_fee is calculated correctly for melt
+        let input_proofs = proofs(&[64, 32, 8, 4]); // 108 total
+        let target_amounts = amounts(&[64, 32, 4]); // 100 split
+        let keyset_fees = keyset_fees_with_ppk(1000); // 1 sat per proof
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &target_amounts,
+            Amount::from(98),
+            Amount::from(2), // target fee for 3 proofs
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // 8 doesn't match, goes to swap
+        // swap_fee should be 1 sat (1 proof * 1000 ppk / 1000)
+        if !result.proofs_to_swap.is_empty() {
+            assert_eq!(
+                result.swap_fee,
+                Amount::from(result.proofs_to_swap.len() as u64)
+            );
+        }
+    }
+
+    #[test]
+    fn test_melt_large_quote_partial_match() {
+        // Realistic melt: input proofs don't contain all target denominations
+        // Input: [512, 256, 128, 64, 32, 16] = 1008
+        // Target: [512, 256, 128, 64, 32, 8, 4, 2, 1] = 1007 (need 8, 4, 2, 1 from swap)
+        let input_proofs = proofs(&[512, 256, 128, 64, 32, 16]);
+        let target_amounts = amounts(&[512, 256, 128, 64, 32, 8, 4, 2, 1]);
+        let keyset_fees = keyset_fees_with_ppk(375);
+
+        let result = split_proofs_for_send(
+            input_proofs,
+            &target_amounts,
+            Amount::from(1004),
+            Amount::from(3),
+            &keyset_fees,
+            false,
+            false,
+        )
+        .unwrap();
+
+        // Check that matched proofs are in proofs_to_send
+        let send_amounts: Vec<u64> = result
+            .proofs_to_send
+            .iter()
+            .map(|p| p.amount.into())
+            .collect();
+
+        // These should match
+        assert!(send_amounts.contains(&512));
+        assert!(send_amounts.contains(&256));
+        assert!(send_amounts.contains(&128));
+        assert!(send_amounts.contains(&64));
+        assert!(send_amounts.contains(&32));
+
+        // 16 doesn't match any target, should be in swap to produce 8+4+2+1=15
+        let swap_amounts: Vec<u64> = result
+            .proofs_to_swap
+            .iter()
+            .map(|p| p.amount.into())
+            .collect();
+        assert!(swap_amounts.contains(&16));
+    }
 }

+ 64 - 0
meetings/2025-12-03-agenda.md

@@ -0,0 +1,64 @@
+# CDK Development Meeting
+
+Dec 03 2025 15:00 UTC
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+## Merged
+
+- [#1381](https://github.com/cashubtc/cdk/pull/1381) - Return TransactionUnbalanced error for empty swap inputs/outputs
+- [#1374](https://github.com/cashubtc/cdk/pull/1374) - Add MultiMintWallet function to check proofs state
+- [#1373](https://github.com/cashubtc/cdk/pull/1373) - Swap before melt
+- [#1367](https://github.com/cashubtc/cdk/pull/1367) - Fix connection pool resource initialization and path validation
+- [#1363](https://github.com/cashubtc/cdk/pull/1363) - fix: wasm use web_time
+- [#1362](https://github.com/cashubtc/cdk/pull/1362) - Add Python wallet test suite with CI integration
+- [#1361](https://github.com/cashubtc/cdk/pull/1361) - feat(cdk): add get_token_data and get_mint_keysets to MultiMintWallet
+- [#1359](https://github.com/cashubtc/cdk/pull/1359) - feat: kill mutants nov 30
+- [#1357](https://github.com/cashubtc/cdk/pull/1357) - Melt external
+- [#1353](https://github.com/cashubtc/cdk/pull/1353) - Allow multiple melt quotes for a payment request
+- [#1349](https://github.com/cashubtc/cdk/pull/1349) - fix: do not remove melt quote
+- [#1348](https://github.com/cashubtc/cdk/pull/1348) - Weekly Meeting Agenda - 2025-11-26
+- [#1345](https://github.com/cashubtc/cdk/pull/1345) - melt fees
+- [#1327](https://github.com/cashubtc/cdk/pull/1327) - Get proofs for tx
+
+## New
+
+### Issues
+
+- [#1377](https://github.com/cashubtc/cdk/issues/1377) - Race condition in tests
+- [#1368](https://github.com/cashubtc/cdk/issues/1368) - Misleading error messages for cdk-mintd binary and config incompatibility
+- [#1364](https://github.com/cashubtc/cdk/issues/1364) - Ensure cdk is up to date with HTLC and P2PK changes
+- [#1356](https://github.com/cashubtc/cdk/issues/1356) - Error message improvement: The error message ("Unit mismatch") when a swap results in zero outputs, which can happen due to fees, isn't very helpful
+- [#1355](https://github.com/cashubtc/cdk/issues/1355) - 🧬 Weekly Mutation Testing Report - 2025-11-28
+
+### PRs
+
+- [#1379](https://github.com/cashubtc/cdk/pull/1379) - fix: regtest start up
+- [#1378](https://github.com/cashubtc/cdk/pull/1378) - feat: use nix for psgl
+- [#1375](https://github.com/cashubtc/cdk/pull/1375) - Add comprehensive test coverage for all mint database functions
+- [#1351](https://github.com/cashubtc/cdk/pull/1351) - Add Payment Requests to FFI
+
+## Recently Active
+
+- [#1337](https://github.com/cashubtc/cdk/pull/1337) - Simplify increment_issued_quote implementation
+- [#1311](https://github.com/cashubtc/cdk/pull/1311) - feat: add operations table
+- [#1303](https://github.com/cashubtc/cdk/pull/1303) - New get pending
+- [#1257](https://github.com/cashubtc/cdk/pull/1257) - bring signatory up to date with the remote signer spec
+- [#1204](https://github.com/cashubtc/cdk/pull/1204) - Add database transaction trait for cdk wallet
+- [#1132](https://github.com/cashubtc/cdk/pull/1132) - Quote id as lookup
+
+### Backports
+- [#1372](https://github.com/cashubtc/cdk/pull/1372) - backport(#1361): add get_token_data and get_mint_keysets to MultiMintWallet
+- [#1380](https://github.com/cashubtc/cdk/issues/1380) - Backport PR `#1373` Swap before melt
+- [#1369](https://github.com/cashubtc/cdk/pull/1369) - [Backport v0.14.x] Fix connection pool resource initialization and path validation
+- [#1366](https://github.com/cashubtc/cdk/pull/1366) - [Backport v0.14.x] fix: wasm use web_time
+- [#1358](https://github.com/cashubtc/cdk/pull/1358) - [Backport v0.14.x] Melt external
+- [#1352](https://github.com/cashubtc/cdk/pull/1352) - [Backport v0.14.x] fix: do not remove melt quote
+- [#1350](https://github.com/cashubtc/cdk/pull/1350) - [Backport v0.14.x] melt fees
+- [#1347](https://github.com/cashubtc/cdk/pull/1347) - [Backport v0.14.x] fix: use the client id from mint configuration
+- [#1382](https://github.com/cashubtc/cdk/pull/1382) - [Backport v0.14.x] Return TransactionUnbalanced error for empty swap inputs/outputs
+- [#1376](https://github.com/cashubtc/cdk/pull/1376) - [Backport v0.14.x] Add MultiMintWallet function to check proofs state
+
+## Discussion
+
+- None