Explorar o código

Merge branch 'main' into main

C hai 2 meses
pai
achega
8cf3b2d992
Modificáronse 58 ficheiros con 1576 adicións e 823 borrados
  1. 15 0
      CHANGELOG.md
  2. 2 0
      README.md
  3. 3 104
      crates/cashu/src/lib.rs
  4. 2 2
      crates/cashu/src/nuts/mod.rs
  5. 27 19
      crates/cashu/src/nuts/nut13.rs
  6. 1 1
      crates/cashu/src/nuts/nut18/payment_request.rs
  7. 0 0
      crates/cashu/src/nuts/nut25.rs
  8. 100 0
      crates/cashu/src/quote_id.rs
  9. 5 4
      crates/cdk-cln/src/lib.rs
  10. 12 6
      crates/cdk-common/src/database/mint/mod.rs
  11. 4 2
      crates/cdk-common/src/database/mint/test.rs
  12. 9 2
      crates/cdk-common/src/payment.rs
  13. 3 0
      crates/cdk-common/src/wallet.rs
  14. 11 8
      crates/cdk-fake-wallet/src/lib.rs
  15. 18 14
      crates/cdk-integration-tests/tests/bolt12.rs
  16. 1 1
      crates/cdk-integration-tests/tests/fake_auth.rs
  17. 5 5
      crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs
  18. 2 2
      crates/cdk-integration-tests/tests/ldk_node.rs
  19. 4 4
      crates/cdk-integration-tests/tests/regtest.rs
  20. 2 2
      crates/cdk-integration-tests/tests/test_fees.rs
  21. 1 4
      crates/cdk-ldk-node/Cargo.toml
  22. 4 4
      crates/cdk-ldk-node/src/lib.rs
  23. 61 29
      crates/cdk-ldk-node/src/web/handlers/channels.rs
  24. 103 54
      crates/cdk-ldk-node/src/web/handlers/lightning.rs
  25. 54 21
      crates/cdk-ldk-node/src/web/handlers/onchain.rs
  26. 447 144
      crates/cdk-ldk-node/src/web/templates/layout.rs
  27. 1 1
      crates/cdk-ldk-node/src/web/templates/payments.rs
  28. BIN=BIN
      crates/cdk-ldk-node/static/images/bg-dark.jpg
  29. 4 4
      crates/cdk-lnbits/src/lib.rs
  30. 5 4
      crates/cdk-lnd/src/lib.rs
  31. 5 3
      crates/cdk-payment-processor/src/proto/client.rs
  32. 15 11
      crates/cdk-payment-processor/src/proto/server.rs
  33. 1 1
      crates/cdk-postgres/Cargo.toml
  34. 44 3
      crates/cdk-postgres/src/lib.rs
  35. 0 1
      crates/cdk-sql-common/Cargo.toml
  36. 29 123
      crates/cdk-sql-common/src/mint/mod.rs
  37. 2 0
      crates/cdk-sql-common/src/wallet/migrations.rs
  38. 1 0
      crates/cdk-sql-common/src/wallet/migrations/postgres/20250831215438_melt_quote_method.sql
  39. 1 0
      crates/cdk-sql-common/src/wallet/migrations/sqlite/20250831215438_melt_quote_method.sql
  40. 18 6
      crates/cdk-sql-common/src/wallet/mod.rs
  41. 6 0
      crates/cdk/Cargo.toml
  42. 161 0
      crates/cdk/examples/mint-token-bolt12-with-custom-http.rs
  43. 12 8
      crates/cdk/src/mint/mod.rs
  44. 39 26
      crates/cdk/src/pub_sub.rs
  45. 16 1
      crates/cdk/src/wallet/builder.rs
  46. 1 1
      crates/cdk/src/wallet/issue/issue_bolt12.rs
  47. 9 1
      crates/cdk/src/wallet/melt/melt_bolt11.rs
  48. 2 0
      crates/cdk/src/wallet/melt/melt_bolt12.rs
  49. 60 156
      crates/cdk/src/wallet/mint_connector/http_client.rs
  50. 6 3
      crates/cdk/src/wallet/mint_connector/mod.rs
  51. 182 0
      crates/cdk/src/wallet/mint_connector/transport.rs
  52. 4 0
      crates/cdk/src/wallet/mod.rs
  53. 21 1
      crates/cdk/src/wallet/subscription/http.rs
  54. 9 1
      crates/cdk/src/wallet/subscription/mod.rs
  55. 10 26
      crates/cdk/src/wallet/subscription/ws.rs
  56. 9 9
      flake.lock
  57. 5 1
      flake.nix
  58. 2 0
      justfile

+ 15 - 0
CHANGELOG.md

@@ -6,6 +6,21 @@
 
 ## [Unreleased]
 
+### Added
+- cdk-common: New `Event` enum for payment event handling with `PaymentReceived` variant ([thesimplekid]).
+- cdk-common: Added `payment_method` field to `MeltQuote` struct for tracking payment method type ([thesimplekid]).
+- cdk-sql-common: Database migration to add `payment_method` column to melt_quote table for SQLite and PostgreSQL ([thesimplekid]).
+
+### Changed
+- cdk-common: Refactored `MintPayment` trait method `wait_any_incoming_payment` to `wait_payment_event` with event-driven architecture ([thesimplekid]).
+- cdk-common: Updated `wait_payment_event` return type to stream `Event` enum instead of `WaitPaymentResponse` directly ([thesimplekid]).
+- cdk: Updated mint payment handling to process payment events through new `Event` enum pattern ([thesimplekid]).
+- cashu: Updated BOLT12 payment method specification from NUT-24 to NUT-25 ([thesimplekid]).
+- cdk: Updated BOLT12 import references from nut24 to nut25 module ([thesimplekid]).
+
+### Fixied
+- cdk: Wallet melt track and use payment method from quote for BOLT11/BOLT12 routing ([thesimplekid]).
+
 ## [0.12.0](https://github.com/cashubtc/cdk/releases/tag/v0.12.0)
 
 ### Summary

+ 2 - 0
README.md

@@ -87,6 +87,7 @@ gossip_source_type = "rgs"
 | [21][21] | Clear Authentication | :heavy_check_mark: |
 | [22][22] | Blind Authentication  | :heavy_check_mark: |
 | [23][23] | Payment Method: BOLT11 | :heavy_check_mark: |
+| [25][25] | Payment Method: BOLT12 | :heavy_check_mark: |
 
 
 ## License
@@ -126,3 +127,4 @@ Please see the [development guide](DEVELOPMENT.md).
 [21]: https://github.com/cashubtc/nuts/blob/main/21.md
 [22]: https://github.com/cashubtc/nuts/blob/main/22.md
 [23]: https://github.com/cashubtc/nuts/blob/main/23.md
+[25]: https://github.com/cashubtc/nuts/blob/main/25.md

+ 3 - 104
crates/cashu/src/lib.rs

@@ -16,6 +16,9 @@ pub use self::mint_url::MintUrl;
 pub use self::nuts::*;
 pub use self::util::SECP256K1;
 
+#[cfg(feature = "mint")]
+pub mod quote_id;
+
 #[doc(hidden)]
 #[macro_export]
 macro_rules! ensure_cdk {
@@ -25,107 +28,3 @@ macro_rules! ensure_cdk {
         }
     };
 }
-
-#[cfg(feature = "mint")]
-/// Quote ID. The specifications only define a string but CDK uses Uuid, so we use an enum to port compatibility.
-pub mod quote_id {
-    use std::fmt;
-    use std::str::FromStr;
-
-    use bitcoin::base64::engine::general_purpose;
-    use bitcoin::base64::Engine as _;
-    use serde::{de, Deserialize, Deserializer, Serialize};
-    use thiserror::Error;
-    use uuid::Uuid;
-
-    /// Invalid UUID
-    #[derive(Debug, Error)]
-    pub enum QuoteIdError {
-        /// UUID Error
-        #[error("invalid UUID: {0}")]
-        Uuid(#[from] uuid::Error),
-        /// Invalid base64
-        #[error("invalid base64")]
-        Base64,
-        /// Invalid quote ID
-        #[error("neither a valid UUID nor a valid base64 string")]
-        InvalidQuoteId,
-    }
-
-    /// Mint Quote ID
-    #[derive(Serialize, Debug, Clone, PartialEq, PartialOrd, Ord, Eq, Hash)]
-    #[serde(untagged)]
-    pub enum QuoteId {
-        /// (Nutshell) base64 quote ID
-        BASE64(String),
-        /// UUID quote ID
-        UUID(Uuid),
-    }
-
-    impl QuoteId {
-        /// Create a new UUID-based MintQuoteId
-        pub fn new_uuid() -> Self {
-            Self::UUID(Uuid::new_v4())
-        }
-    }
-
-    impl From<Uuid> for QuoteId {
-        fn from(uuid: Uuid) -> Self {
-            Self::UUID(uuid)
-        }
-    }
-
-    impl fmt::Display for QuoteId {
-        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-            match self {
-                QuoteId::BASE64(s) => write!(f, "{}", s),
-                QuoteId::UUID(u) => write!(f, "{}", u),
-            }
-        }
-    }
-
-    impl FromStr for QuoteId {
-        type Err = QuoteIdError;
-
-        fn from_str(s: &str) -> Result<Self, Self::Err> {
-            // Try UUID first
-            if let Ok(u) = Uuid::parse_str(s) {
-                return Ok(QuoteId::UUID(u));
-            }
-
-            // Try base64: decode, then re-encode and compare to ensure canonical form
-            // Use the standard (URL/filename safe or standard) depending on your needed alphabet.
-            // Here we use standard base64.
-            match general_purpose::URL_SAFE.decode(s) {
-                Ok(_bytes) => Ok(QuoteId::BASE64(s.to_string())),
-                Err(_) => Err(QuoteIdError::InvalidQuoteId),
-            }
-        }
-    }
-
-    impl<'de> Deserialize<'de> for QuoteId {
-        fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-        where
-            D: Deserializer<'de>,
-        {
-            // Deserialize as plain string first
-            let s = String::deserialize(deserializer)?;
-
-            // Try UUID first
-            if let Ok(u) = Uuid::parse_str(&s) {
-                return Ok(QuoteId::UUID(u));
-            }
-
-            if general_purpose::URL_SAFE.decode(&s).is_ok() {
-                return Ok(QuoteId::BASE64(s));
-            }
-
-            // Neither matched — return a helpful error
-            Err(de::Error::custom(format!(
-                "QuoteId must be either a UUID (e.g. {}) or a valid base64 string; got: {}",
-                Uuid::nil(),
-                s
-            )))
-        }
-    }
-}

+ 2 - 2
crates/cashu/src/nuts/mod.rs

@@ -24,7 +24,7 @@ pub mod nut18;
 pub mod nut19;
 pub mod nut20;
 pub mod nut23;
-pub mod nut24;
+pub mod nut25;
 
 #[cfg(feature = "auth")]
 mod auth;
@@ -68,4 +68,4 @@ pub use nut23::{
     MeltOptions, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintQuoteBolt11Request,
     MintQuoteBolt11Response, QuoteState as MintQuoteState,
 };
-pub use nut24::{MeltQuoteBolt12Request, MintQuoteBolt12Request, MintQuoteBolt12Response};
+pub use nut25::{MeltQuoteBolt12Request, MintQuoteBolt12Request, MintQuoteBolt12Response};

+ 27 - 19
crates/cashu/src/nuts/nut13.rs

@@ -3,7 +3,7 @@
 //! <https://github.com/cashubtc/nuts/blob/main/13.md>
 
 use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv};
-use bitcoin::secp256k1::hashes::{hmac, sha512, Hash, HashEngine, HmacEngine};
+use bitcoin::secp256k1::hashes::{hmac, sha256, Hash, HashEngine, HmacEngine};
 use bitcoin::{secp256k1, Network};
 use thiserror::Error;
 use tracing::instrument;
@@ -66,14 +66,14 @@ impl Secret {
 
     fn derive(seed: &[u8; 64], keyset_id: Id, counter: u32) -> Result<Self, Error> {
         let mut message = Vec::new();
-        message.extend_from_slice(b"Cashu_KDF_HMAC_SHA512");
+        message.extend_from_slice(b"Cashu_KDF_HMAC_SHA256");
         message.extend_from_slice(&keyset_id.to_bytes());
         message.extend_from_slice(&(counter as u64).to_be_bytes());
         message.extend_from_slice(b"\x00");
 
-        let mut engine = HmacEngine::<sha512::Hash>::new(seed);
+        let mut engine = HmacEngine::<sha256::Hash>::new(seed);
         engine.input(&message);
-        let hmac_result = hmac::Hmac::<sha512::Hash>::from_engine(engine);
+        let hmac_result = hmac::Hmac::<sha256::Hash>::from_engine(engine);
         let result_bytes = hmac_result.to_byte_array();
 
         Ok(Self::new(hex::encode(&result_bytes[..32])))
@@ -101,14 +101,14 @@ impl SecretKey {
 
     fn derive(seed: &[u8; 64], keyset_id: Id, counter: u32) -> Result<Self, Error> {
         let mut message = Vec::new();
-        message.extend_from_slice(b"Cashu_KDF_HMAC_SHA512");
+        message.extend_from_slice(b"Cashu_KDF_HMAC_SHA256");
         message.extend_from_slice(&keyset_id.to_bytes());
         message.extend_from_slice(&(counter as u64).to_be_bytes());
         message.extend_from_slice(b"\x01");
 
-        let mut engine = HmacEngine::<sha512::Hash>::new(seed);
+        let mut engine = HmacEngine::<sha256::Hash>::new(seed);
         engine.input(&message);
-        let hmac_result = hmac::Hmac::<sha512::Hash>::from_engine(engine);
+        let hmac_result = hmac::Hmac::<sha256::Hash>::from_engine(engine);
         let result_bytes = hmac_result.to_byte_array();
 
         Ok(Self::from(secp256k1::SecretKey::from_slice(
@@ -316,26 +316,26 @@ mod tests {
 
         // Test with a v2 keyset ID (33 bytes, starting with "01")
         let keyset_id =
-            Id::from_str("01adc013fa9d85171586660abab27579888611659d357bc86bc09cb26eee8bc035")
+            Id::from_str("012e23479a0029432eaad0d2040c09be53bab592d5cbf1d55e0dd26c9495951b30")
                 .unwrap();
 
         // Expected secrets derived using the new derivation
         let test_secrets = [
-            "f24ca2e4e5c8e1e8b43e3d0d9e9d4c2a1b6a5e9f8c7b3d2e1f0a9b8c7d6e5f4a",
-            "8b7e5f9a4d3c2b1e7f6a5d9c8b4e3f2a6b5c9d8e7f4a3b2e1f5a9c8d7b6e4f3",
-            "e9f8c7b6a5d4c3b2a1f9e8d7c6b5a4d3c2b1f0e9d8c7b6a5f4e3d2c1b0a9f8e7",
-            "a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2",
-            "d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c6",
+            "ba250bf927b1df5dd0a07c543be783a4349a7f99904acd3406548402d3484118",
+            "3a6423fe56abd5e74ec9d22a91ee110cd2ce45a7039901439d62e5534d3438c1",
+            "843484a75b78850096fac5b513e62854f11d57491cf775a6fd2edf4e583ae8c0",
+            "3600608d5cf8197374f060cfbcff134d2cd1fb57eea68cbcf2fa6917c58911b6",
+            "717fce9cc6f9ea060d20dd4e0230af4d63f3894cc49dd062fd99d033ea1ac1dd",
         ];
 
-        for (i, _test_secret) in test_secrets.iter().enumerate() {
+        for (i, test_secret) in test_secrets.iter().enumerate() {
             let secret = Secret::from_seed(&seed, keyset_id, i.try_into().unwrap()).unwrap();
             // Note: The actual expected values would need to be computed from a reference implementation
             // For now, we just verify the derivation works and produces consistent results
             assert_eq!(secret.to_string().len(), 64); // Should be 32 bytes = 64 hex chars
 
             // Test deterministic derivation: same inputs should produce same outputs
-            let secret2 = Secret::from_seed(&seed, keyset_id, i.try_into().unwrap()).unwrap();
+            let secret2 = Secret::from_str(test_secret).unwrap();
             assert_eq!(secret, secret2);
         }
     }
@@ -349,18 +349,26 @@ mod tests {
 
         // Test with a v2 keyset ID (33 bytes, starting with "01")
         let keyset_id =
-            Id::from_str("01adc013fa9d85171586660abab27579888611659d357bc86bc09cb26eee8bc035")
+            Id::from_str("012e23479a0029432eaad0d2040c09be53bab592d5cbf1d55e0dd26c9495951b30")
                 .unwrap();
 
-        for i in 0..5 {
-            let secret_key = SecretKey::from_seed(&seed, keyset_id, i).unwrap();
+        let test_secret_keys = [
+            "4f8b32a54aed811b692a665ed296b4c1fc2f37a8be4006379e95063a76693745",
+            "c4b8412ee644067007423480c9e556385b71ffdff0f340bc16a95c0534fe0e01",
+            "ceff40983441c40acaf77d2a8ddffd5c1c84391fb9fd0dc4607c186daab1c829",
+            "41ad26b840fb62d29b2318a82f1d9cd40dc0f1e58183cc57562f360a32fdfad6",
+            "fb986a9c76758593b0e2d1a5172ade977c858d87111a220e16c292a9347abf81",
+        ];
+
+        for (i, test_secret) in test_secret_keys.iter().enumerate() {
+            let secret_key = SecretKey::from_seed(&seed, keyset_id, i as u32).unwrap();
 
             // Verify the secret key is valid (32 bytes)
             let secret_bytes = secret_key.secret_bytes();
             assert_eq!(secret_bytes.len(), 32);
 
             // Test deterministic derivation
-            let secret_key2 = SecretKey::from_seed(&seed, keyset_id, i).unwrap();
+            let secret_key2 = SecretKey::from_str(test_secret).unwrap();
             assert_eq!(secret_key, secret_key2);
         }
     }

+ 1 - 1
crates/cashu/src/nuts/nut18/payment_request.rs

@@ -288,7 +288,7 @@ mod tests {
         assert_eq!(request.unit.clone().unwrap(), CurrencyUnit::Sat);
         assert_eq!(request.mints.clone().unwrap(), vec![mint_url]);
 
-        let t = request.transports.first().clone().unwrap();
+        let t = request.transports.first().unwrap();
         assert_eq!(&transport, t);
 
         // Test serialization and deserialization

+ 0 - 0
crates/cashu/src/nuts/nut24.rs → crates/cashu/src/nuts/nut25.rs


+ 100 - 0
crates/cashu/src/quote_id.rs

@@ -0,0 +1,100 @@
+//! Quote ID. The specifications only define a string but CDK uses Uuid, so we use an enum to port compatibility.
+use std::fmt;
+use std::str::FromStr;
+
+use bitcoin::base64::engine::general_purpose;
+use bitcoin::base64::Engine as _;
+use serde::{de, Deserialize, Deserializer, Serialize};
+use thiserror::Error;
+use uuid::Uuid;
+
+/// Invalid UUID
+#[derive(Debug, Error)]
+pub enum QuoteIdError {
+    /// UUID Error
+    #[error("invalid UUID: {0}")]
+    Uuid(#[from] uuid::Error),
+    /// Invalid base64
+    #[error("invalid base64")]
+    Base64,
+    /// Invalid quote ID
+    #[error("neither a valid UUID nor a valid base64 string")]
+    InvalidQuoteId,
+}
+
+/// Mint Quote ID
+#[derive(Serialize, Debug, Clone, PartialEq, PartialOrd, Ord, Eq, Hash)]
+#[serde(untagged)]
+pub enum QuoteId {
+    /// (Nutshell) base64 quote ID
+    BASE64(String),
+    /// UUID quote ID
+    UUID(Uuid),
+}
+
+impl QuoteId {
+    /// Create a new UUID-based MintQuoteId
+    pub fn new_uuid() -> Self {
+        Self::UUID(Uuid::new_v4())
+    }
+}
+
+impl From<Uuid> for QuoteId {
+    fn from(uuid: Uuid) -> Self {
+        Self::UUID(uuid)
+    }
+}
+
+impl fmt::Display for QuoteId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            QuoteId::BASE64(s) => write!(f, "{}", s),
+            QuoteId::UUID(u) => write!(f, "{}", u.hyphenated()),
+        }
+    }
+}
+
+impl FromStr for QuoteId {
+    type Err = QuoteIdError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        // Try UUID first
+        if let Ok(u) = Uuid::parse_str(s) {
+            return Ok(QuoteId::UUID(u));
+        }
+
+        // Try base64: decode, then re-encode and compare to ensure canonical form
+        // Use the standard (URL/filename safe or standard) depending on your needed alphabet.
+        // Here we use standard base64.
+        match general_purpose::URL_SAFE.decode(s) {
+            Ok(_bytes) => Ok(QuoteId::BASE64(s.to_string())),
+            Err(_) => Err(QuoteIdError::InvalidQuoteId),
+        }
+    }
+}
+
+impl<'de> Deserialize<'de> for QuoteId {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        // Deserialize as plain string first
+        let s = String::deserialize(deserializer)?;
+
+        // Try UUID first
+        if let Ok(u) = Uuid::parse_str(&s) {
+            return Ok(QuoteId::UUID(u));
+        }
+
+        if general_purpose::URL_SAFE.decode(&s).is_ok() {
+            return Ok(QuoteId::BASE64(s));
+        }
+
+        // Neither matched — return a helpful error
+        Err(de::Error::custom(format!(
+            "QuoteId must be either a UUID (e.g. {}) or a valid base64 string; got: {}",
+            Uuid::nil(),
+            s
+        )))
+    }
+}

+ 5 - 4
crates/cdk-cln/src/lib.rs

@@ -19,7 +19,7 @@ use cdk_common::common::FeeReserve;
 use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState};
 use cdk_common::payment::{
     self, Bolt11IncomingPaymentOptions, Bolt11Settings, Bolt12IncomingPaymentOptions,
-    CreateIncomingPaymentResponse, IncomingPaymentOptions, MakePaymentResponse, MintPayment,
+    CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, MakePaymentResponse, MintPayment,
     OutgoingPaymentOptions, PaymentIdentifier, PaymentQuoteResponse, WaitPaymentResponse,
 };
 use cdk_common::util::{hex, unix_time};
@@ -89,9 +89,9 @@ impl MintPayment for Cln {
     }
 
     #[instrument(skip_all)]
-    async fn wait_any_incoming_payment(
+    async fn wait_payment_event(
         &self,
-    ) -> Result<Pin<Box<dyn Stream<Item = WaitPaymentResponse> + Send>>, Self::Err> {
+    ) -> Result<Pin<Box<dyn Stream<Item = Event> + Send>>, Self::Err> {
         tracing::info!(
             "CLN: Starting wait_any_incoming_payment with socket: {:?}",
             self.rpc_socket
@@ -243,8 +243,9 @@ impl MintPayment for Cln {
                                 payment_id: payment_hash.to_string()
                             };
                             tracing::info!("CLN: Created WaitPaymentResponse with amount {} msats", amount_msats.msat());
+                            let event = Event::PaymentReceived(response);
 
-                            break Some((response, (cln_client, last_pay_idx, cancel_token, is_active)));
+                            break Some((event, (cln_client, last_pay_idx, cancel_token, is_active)));
                                 }
                                 Err(e) => {
                                     tracing::warn!("CLN: Error fetching invoice: {e}");

+ 12 - 6
crates/cdk-common/src/database/mint/mod.rs

@@ -5,7 +5,6 @@ use std::collections::HashMap;
 use async_trait::async_trait;
 use cashu::quote_id::QuoteId;
 use cashu::{Amount, MintInfo};
-use uuid::Uuid;
 
 use super::Error;
 use crate::common::QuoteTTL;
@@ -89,7 +88,7 @@ pub trait QuotesTransaction<'a> {
     /// Get [`mint::MeltQuote`] and lock it for update in this transaction
     async fn get_melt_quote(
         &mut self,
-        quote_id: &Uuid,
+        quote_id: &QuoteId,
     ) -> Result<Option<mint::MeltQuote>, Self::Err>;
     /// Add [`mint::MeltQuote`]
     async fn add_melt_quote(&mut self, quote: mint::MeltQuote) -> Result<(), Self::Err>;
@@ -111,7 +110,7 @@ pub trait QuotesTransaction<'a> {
         payment_proof: Option<String>,
     ) -> Result<(MeltQuoteState, mint::MeltQuote), Self::Err>;
     /// Remove [`mint::MeltQuote`]
-    async fn remove_melt_quote(&mut self, quote_id: &Uuid) -> Result<(), Self::Err>;
+    async fn remove_melt_quote(&mut self, quote_id: &QuoteId) -> Result<(), Self::Err>;
     /// Get all [`MintMintQuote`]s and lock it for update in this transaction
     async fn get_mint_quote_by_request(
         &mut self,
@@ -165,7 +164,11 @@ pub trait ProofsTransaction<'a> {
     ///
     /// Adds proofs to the database. The database should error if the proof already exits, with a
     /// `AttemptUpdateSpentProof` if the proof is already spent or a `Duplicate` error otherwise.
-    async fn add_proofs(&mut self, proof: Proofs, quote_id: Option<Uuid>) -> Result<(), Self::Err>;
+    async fn add_proofs(
+        &mut self,
+        proof: Proofs,
+        quote_id: Option<QuoteId>,
+    ) -> Result<(), Self::Err>;
     /// Updates the proofs to a given states and return the previous states
     async fn update_proofs_states(
         &mut self,
@@ -177,7 +180,7 @@ pub trait ProofsTransaction<'a> {
     async fn remove_proofs(
         &mut self,
         ys: &[PublicKey],
-        quote_id: Option<Uuid>,
+        quote_id: Option<QuoteId>,
     ) -> Result<(), Self::Err>;
 }
 
@@ -190,7 +193,10 @@ pub trait ProofsDatabase {
     /// Get [`Proofs`] by ys
     async fn get_proofs_by_ys(&self, ys: &[PublicKey]) -> Result<Vec<Option<Proof>>, Self::Err>;
     /// Get ys by quote id
-    async fn get_proof_ys_by_quote_id(&self, quote_id: &Uuid) -> Result<Vec<PublicKey>, Self::Err>;
+    async fn get_proof_ys_by_quote_id(
+        &self,
+        quote_id: &QuoteId,
+    ) -> Result<Vec<PublicKey>, Self::Err>;
     /// Get [`Proofs`] state
     async fn get_proofs_states(&self, ys: &[PublicKey]) -> Result<Vec<Option<State>>, Self::Err>;
     /// Get [`Proofs`] by state

+ 4 - 2
crates/cdk-common/src/database/mint/test.rs

@@ -87,7 +87,7 @@ where
 {
     let keyset_id = setup_keyset(&db).await;
 
-    let quote_id = Uuid::max();
+    let quote_id = QuoteId::new_uuid();
 
     let proofs = vec![
         Proof {
@@ -110,7 +110,9 @@ where
 
     // Add proofs to database
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_proofs(proofs.clone(), Some(quote_id)).await.unwrap();
+    tx.add_proofs(proofs.clone(), Some(quote_id.clone()))
+        .await
+        .unwrap();
     assert!(tx.commit().await.is_ok());
 
     let proofs_from_db = db.get_proofs_by_ys(&[proofs[0].c, proofs[1].c]).await;

+ 9 - 2
crates/cdk-common/src/payment.rs

@@ -295,9 +295,9 @@ pub trait MintPayment {
 
     /// Listen for invoices to be paid to the mint
     /// Returns a stream of request_lookup_id once invoices are paid
-    async fn wait_any_incoming_payment(
+    async fn wait_payment_event(
         &self,
-    ) -> Result<Pin<Box<dyn Stream<Item = WaitPaymentResponse> + Send>>, Self::Err>;
+    ) -> Result<Pin<Box<dyn Stream<Item = Event> + Send>>, Self::Err>;
 
     /// Is wait invoice active
     fn is_wait_invoice_active(&self) -> bool;
@@ -318,6 +318,13 @@ pub trait MintPayment {
     ) -> Result<MakePaymentResponse, Self::Err>;
 }
 
+/// An event emitted which should be handled by the mint
+#[derive(Debug, Clone, Hash)]
+pub enum Event {
+    /// A payment has been received.
+    PaymentReceived(WaitPaymentResponse),
+}
+
 /// Wait any invoice response
 #[derive(Debug, Clone, Hash, Serialize, Deserialize)]
 pub struct WaitPaymentResponse {

+ 3 - 0
crates/cdk-common/src/wallet.rs

@@ -84,6 +84,9 @@ pub struct MeltQuote {
     pub expiry: u64,
     /// Payment preimage
     pub payment_preimage: Option<String>,
+    /// Payment method
+    #[serde(default)]
+    pub payment_method: PaymentMethod,
 }
 
 impl MintQuote {

+ 11 - 8
crates/cdk-fake-wallet/src/lib.rs

@@ -27,7 +27,7 @@ use cdk_common::common::FeeReserve;
 use cdk_common::ensure_cdk;
 use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState};
 use cdk_common::payment::{
-    self, Bolt11Settings, CreateIncomingPaymentResponse, IncomingPaymentOptions,
+    self, Bolt11Settings, CreateIncomingPaymentResponse, Event, IncomingPaymentOptions,
     MakePaymentResponse, MintPayment, OutgoingPaymentOptions, PaymentIdentifier,
     PaymentQuoteResponse, WaitPaymentResponse,
 };
@@ -295,9 +295,9 @@ impl MintPayment for FakeWallet {
     }
 
     #[instrument(skip_all)]
-    async fn wait_any_incoming_payment(
+    async fn wait_payment_event(
         &self,
-    ) -> Result<Pin<Box<dyn Stream<Item = WaitPaymentResponse> + Send>>, Self::Err> {
+    ) -> Result<Pin<Box<dyn Stream<Item = Event> + Send>>, Self::Err> {
         tracing::info!("Starting stream for fake invoices");
         let receiver = self
             .receiver
@@ -309,11 +309,14 @@ impl MintPayment for FakeWallet {
         let unit = self.unit.clone();
         let receiver_stream = ReceiverStream::new(receiver);
         Ok(Box::pin(receiver_stream.map(
-            move |(request_lookup_id, payment_amount, payment_id)| WaitPaymentResponse {
-                payment_identifier: request_lookup_id.clone(),
-                payment_amount,
-                unit: unit.clone(),
-                payment_id,
+            move |(request_lookup_id, payment_amount, payment_id)| {
+                let wait_response = WaitPaymentResponse {
+                    payment_identifier: request_lookup_id.clone(),
+                    payment_amount,
+                    unit: unit.clone(),
+                    payment_id,
+                };
+                Event::PaymentReceived(wait_response)
             },
         )))
     }

+ 18 - 14
crates/cdk-integration-tests/tests/bolt12.rs

@@ -1,13 +1,14 @@
 use std::env;
 use std::path::PathBuf;
+use std::str::FromStr;
 use std::sync::Arc;
 
 use anyhow::{bail, Result};
 use bip39::Mnemonic;
 use cashu::amount::SplitTarget;
 use cashu::nut23::Amountless;
-use cashu::{Amount, CurrencyUnit, MintRequest, PreMintSecrets, ProofsMethods};
-use cdk::wallet::{HttpClient, MintConnector, Wallet};
+use cashu::{Amount, CurrencyUnit, MintRequest, MintUrl, PreMintSecrets, ProofsMethods};
+use cdk::wallet::{HttpClient, MintConnector, Wallet, WalletBuilder};
 use cdk_integration_tests::get_mint_url_from_env;
 use cdk_integration_tests::init_regtest::{get_cln_dir, get_temp_dir};
 use cdk_sqlite::wallet::memory;
@@ -97,13 +98,16 @@ async fn test_regtest_bolt12_mint() {
 /// - Tests the functionality of reusing a quote for multiple payments
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_regtest_bolt12_mint_multiple() -> Result<()> {
-    let wallet = Wallet::new(
-        &get_mint_url_from_env(),
-        CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
-        Mnemonic::generate(12)?.to_seed_normalized(""),
-        None,
-    )?;
+    let mint_url = MintUrl::from_str(&get_mint_url_from_env())?;
+
+    let wallet = WalletBuilder::new()
+        .mint_url(mint_url)
+        .unit(CurrencyUnit::Sat)
+        .localstore(Arc::new(memory::empty().await?))
+        .seed(Mnemonic::generate(12)?.to_seed_normalized(""))
+        .target_proof_count(3)
+        .use_http_subscription()
+        .build()?;
 
     let mint_quote = wallet.mint_bolt12_quote(None, None).await?;
 
@@ -120,7 +124,7 @@ async fn test_regtest_bolt12_mint_multiple() -> Result<()> {
             mint_quote.clone(),
             SplitTarget::default(),
             None,
-            tokio::time::Duration::from_secs(15),
+            tokio::time::Duration::from_secs(60),
         )
         .await?;
 
@@ -136,7 +140,7 @@ async fn test_regtest_bolt12_mint_multiple() -> Result<()> {
             mint_quote.clone(),
             SplitTarget::default(),
             None,
-            tokio::time::Duration::from_secs(15),
+            tokio::time::Duration::from_secs(60),
         )
         .await?;
 
@@ -187,7 +191,7 @@ async fn test_regtest_bolt12_multiple_wallets() -> Result<()> {
             quote_one.clone(),
             SplitTarget::default(),
             None,
-            tokio::time::Duration::from_secs(15),
+            tokio::time::Duration::from_secs(60),
         )
         .await?;
 
@@ -206,7 +210,7 @@ async fn test_regtest_bolt12_multiple_wallets() -> Result<()> {
             quote_two.clone(),
             SplitTarget::default(),
             None,
-            tokio::time::Duration::from_secs(15),
+            tokio::time::Duration::from_secs(60),
         )
         .await?;
 
@@ -283,7 +287,7 @@ async fn test_regtest_bolt12_melt() -> Result<()> {
             mint_quote.clone(),
             SplitTarget::default(),
             None,
-            tokio::time::Duration::from_secs(15),
+            tokio::time::Duration::from_secs(60),
         )
         .await?;
 

+ 1 - 1
crates/cdk-integration-tests/tests/fake_auth.rs

@@ -336,7 +336,7 @@ async fn test_mint_with_auth() {
             quote.clone(),
             SplitTarget::default(),
             None,
-            tokio::time::Duration::from_secs(15),
+            tokio::time::Duration::from_secs(60),
         )
         .await
         .expect("payment");

+ 5 - 5
crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs

@@ -114,7 +114,7 @@ async fn test_happy_mint_melt_round_trip() {
             mint_quote.clone(),
             SplitTarget::default(),
             None,
-            tokio::time::Duration::from_secs(15),
+            tokio::time::Duration::from_secs(60),
         )
         .await
         .expect("payment");
@@ -236,7 +236,7 @@ async fn test_happy_mint() {
             mint_quote.clone(),
             SplitTarget::default(),
             None,
-            tokio::time::Duration::from_secs(15),
+            tokio::time::Duration::from_secs(60),
         )
         .await
         .expect("payment");
@@ -284,7 +284,7 @@ async fn test_restore() {
             mint_quote.clone(),
             SplitTarget::default(),
             None,
-            tokio::time::Duration::from_secs(15),
+            tokio::time::Duration::from_secs(60),
         )
         .await
         .expect("payment");
@@ -364,7 +364,7 @@ async fn test_fake_melt_change_in_quote() {
             mint_quote.clone(),
             SplitTarget::default(),
             None,
-            tokio::time::Duration::from_secs(15),
+            tokio::time::Duration::from_secs(60),
         )
         .await
         .expect("payment");
@@ -434,7 +434,7 @@ async fn test_pay_invoice_twice() {
             mint_quote.clone(),
             SplitTarget::default(),
             None,
-            tokio::time::Duration::from_secs(15),
+            tokio::time::Duration::from_secs(60),
         )
         .await
         .expect("payment");

+ 2 - 2
crates/cdk-integration-tests/tests/ldk_node.rs

@@ -10,7 +10,7 @@ async fn test_ldk_node_mint_info() -> Result<()> {
     let client = reqwest::Client::new();
 
     // Make a request to the info endpoint
-    let response = client.get(&format!("{}/v1/info", mint_url)).send().await?;
+    let response = client.get(format!("{}/v1/info", mint_url)).send().await?;
 
     // Check that we got a successful response
     assert_eq!(response.status(), 200);
@@ -44,7 +44,7 @@ async fn test_ldk_node_mint_quote() -> Result<()> {
 
     // Make a request to create a mint quote
     let response = client
-        .post(&format!("{}/v1/mint/quote/bolt11", mint_url))
+        .post(format!("{}/v1/mint/quote/bolt11", mint_url))
         .json(&quote_request)
         .send()
         .await?;

+ 4 - 4
crates/cdk-integration-tests/tests/regtest.rs

@@ -56,7 +56,7 @@ async fn test_internal_payment() {
             mint_quote.clone(),
             SplitTarget::default(),
             None,
-            tokio::time::Duration::from_secs(15),
+            tokio::time::Duration::from_secs(60),
         )
         .await
         .expect("payment");
@@ -88,7 +88,7 @@ async fn test_internal_payment() {
             mint_quote.clone(),
             SplitTarget::default(),
             None,
-            tokio::time::Duration::from_secs(15),
+            tokio::time::Duration::from_secs(60),
         )
         .await
         .expect("payment");
@@ -236,7 +236,7 @@ async fn test_multimint_melt() {
             quote.clone(),
             SplitTarget::default(),
             None,
-            tokio::time::Duration::from_secs(15),
+            tokio::time::Duration::from_secs(60),
         )
         .await
         .expect("payment");
@@ -252,7 +252,7 @@ async fn test_multimint_melt() {
             quote.clone(),
             SplitTarget::default(),
             None,
-            tokio::time::Duration::from_secs(15),
+            tokio::time::Duration::from_secs(60),
         )
         .await
         .expect("payment");

+ 2 - 2
crates/cdk-integration-tests/tests/test_fees.rs

@@ -32,7 +32,7 @@ async fn test_swap() {
             mint_quote.clone(),
             SplitTarget::default(),
             None,
-            tokio::time::Duration::from_secs(15),
+            tokio::time::Duration::from_secs(60),
         )
         .await
         .expect("payment");
@@ -92,7 +92,7 @@ async fn test_fake_melt_change_in_quote() {
             mint_quote.clone(),
             SplitTarget::default(),
             None,
-            tokio::time::Duration::from_secs(15),
+            tokio::time::Duration::from_secs(60),
         )
         .await
         .expect("payment");

+ 1 - 4
crates/cdk-ldk-node/Cargo.toml

@@ -15,7 +15,7 @@ async-trait.workspace = true
 axum.workspace = true
 cdk-common = { workspace = true, features = ["mint"] }
 futures.workspace = true
-tokio.workspace = true 
+tokio.workspace = true
 tokio-util.workspace = true
 tracing.workspace = true
 thiserror.workspace = true
@@ -29,6 +29,3 @@ tower-http.workspace = true
 rust-embed = "8.5.0"
 serde_urlencoded = "0.7"
 urlencoding = "2.1"
-
-
-

+ 4 - 4
crates/cdk-ldk-node/src/lib.rs

@@ -823,9 +823,9 @@ impl MintPayment for CdkLdkNode {
     /// Listen for invoices to be paid to the mint
     /// Returns a stream of request_lookup_id once invoices are paid
     #[instrument(skip(self))]
-    async fn wait_any_incoming_payment(
+    async fn wait_payment_event(
         &self,
-    ) -> Result<Pin<Box<dyn Stream<Item = WaitPaymentResponse> + Send>>, Self::Err> {
+    ) -> Result<Pin<Box<dyn Stream<Item = cdk_common::payment::Event> + Send>>, Self::Err> {
         tracing::info!("Starting stream for invoices - wait_any_incoming_payment called");
 
         // Set active flag to indicate stream is active
@@ -839,10 +839,10 @@ impl MintPayment for CdkLdkNode {
         // Transform the String stream into a WaitPaymentResponse stream
         let response_stream = BroadcastStream::new(receiver.resubscribe());
 
-        // Map the stream to handle BroadcastStreamRecvError
+        // Map the stream to handle BroadcastStreamRecvError and wrap in Event
         let response_stream = response_stream.filter_map(|result| async move {
             match result {
-                Ok(payment) => Some(payment),
+                Ok(payment) => Some(cdk_common::payment::Event::PaymentReceived(payment)),
                 Err(err) => {
                     tracing::warn!("Error in broadcast stream: {}", err);
                     None

+ 61 - 29
crates/cdk-ldk-node/src/web/handlers/channels.rs

@@ -213,7 +213,7 @@ pub async fn post_open_channel(
 }
 
 pub async fn close_channel_page(
-    State(_state): State<AppState>,
+    State(state): State<AppState>,
     query: Query<HashMap<String, String>>,
 ) -> Result<Html<String>, StatusCode> {
     let channel_id = query.get("channel_id").unwrap_or(&"".to_string()).clone();
@@ -229,24 +229,40 @@ pub async fn close_channel_page(
         return Ok(Html(layout("Close Channel Error", content).into_string()));
     }
 
+    // Get channel information for amount display
+    let channels = state.node.inner.list_channels();
+    let channel = channels
+        .iter()
+        .find(|c| c.user_channel_id.0.to_string() == channel_id);
+
     let content = form_card(
         "Close Channel",
         html! {
-            p { "Are you sure you want to close this channel?" }
-            div class="info-item" {
-                span class="info-label" { "User Channel ID:" }
-                span class="info-value" style="font-family: monospace; font-size: 0.85rem;" { (channel_id) }
-            }
-            div class="info-item" {
-                span class="info-label" { "Node ID:" }
-                span class="info-value" style="font-family: monospace; font-size: 0.85rem;" { (node_id) }
+            p style="margin-bottom: 1.5rem;" { "Are you sure you want to close this channel?" }
+
+            // Channel details in consistent format
+            div class="channel-details" {
+                div class="detail-row" {
+                    span class="detail-label" { "User Channel ID" }
+                    span class="detail-value-amount" { (channel_id) }
+                }
+                div class="detail-row" {
+                    span class="detail-label" { "Node ID" }
+                    span class="detail-value-amount" { (node_id) }
+                }
+                @if let Some(ch) = channel {
+                    div class="detail-row" {
+                        span class="detail-label" { "Channel Amount" }
+                        span class="detail-value-amount" { (format_sats_as_btc(ch.channel_value_sats)) }
+                    }
+                }
             }
-            form method="post" action="/channels/close" style="margin-top: 1rem;" {
+
+            form method="post" action="/channels/close" style="margin-top: 1rem; display: flex; justify-content: space-between; align-items: center;" {
                 input type="hidden" name="channel_id" value=(channel_id) {}
                 input type="hidden" name="node_id" value=(node_id) {}
-                button type="submit" style="background: #dc3545;" { "Close Channel" }
-                " "
-                a href="/balance" { button type="button" { "Cancel" } }
+                a href="/balance" { button type="button" class="button-secondary" { "Cancel" } }
+                button type="submit" class="button-destructive" { "Close Channel" }
             }
         },
     );
@@ -255,7 +271,7 @@ pub async fn close_channel_page(
 }
 
 pub async fn force_close_channel_page(
-    State(_state): State<AppState>,
+    State(state): State<AppState>,
     query: Query<HashMap<String, String>>,
 ) -> Result<Html<String>, StatusCode> {
     let channel_id = query.get("channel_id").unwrap_or(&"".to_string()).clone();
@@ -273,32 +289,48 @@ pub async fn force_close_channel_page(
         ));
     }
 
+    // Get channel information for amount display
+    let channels = state.node.inner.list_channels();
+    let channel = channels
+        .iter()
+        .find(|c| c.user_channel_id.0.to_string() == channel_id);
+
     let content = form_card(
         "Force Close Channel",
         html! {
-            div style="border: 2px solid #d63384; background-color: rgba(214, 51, 132, 0.1); padding: 1rem; margin-bottom: 1rem; border-radius: 0.5rem;" {
-                h4 style="color: #d63384; margin: 0 0 0.5rem 0;" { "⚠️ Warning: Force Close" }
-                p style="color: #d63384; margin: 0; font-size: 0.9rem;" {
+            div style="border: 2px solid #f97316; background-color: rgba(249, 115, 22, 0.1); padding: 1rem; margin-bottom: 1rem; border-radius: 0.5rem;" {
+                h4 style="color: #f97316; margin: 0 0 0.5rem 0;" { "⚠️ Warning: Force Close" }
+                p style="color: #f97316; margin: 0; font-size: 0.9rem;" {
                     "Force close should NOT be used if normal close is preferred. "
                     "Force close will immediately broadcast the latest commitment transaction and may result in delayed fund recovery. "
                     "Only use this if the channel counterparty is unresponsive or there are other issues preventing normal closure."
                 }
             }
-            p { "Are you sure you want to force close this channel?" }
-            div class="info-item" {
-                span class="info-label" { "User Channel ID:" }
-                span class="info-value" style="font-family: monospace; font-size: 0.85rem;" { (channel_id) }
-            }
-            div class="info-item" {
-                span class="info-label" { "Node ID:" }
-                span class="info-value" style="font-family: monospace; font-size: 0.85rem;" { (node_id) }
+            p style="margin-bottom: 1.5rem;" { "Are you sure you want to force close this channel?" }
+
+            // Channel details in consistent format
+            div class="channel-details" {
+                div class="detail-row" {
+                    span class="detail-label" { "User Channel ID" }
+                    span class="detail-value-amount" { (channel_id) }
+                }
+                div class="detail-row" {
+                    span class="detail-label" { "Node ID" }
+                    span class="detail-value-amount" { (node_id) }
+                }
+                @if let Some(ch) = channel {
+                    div class="detail-row" {
+                        span class="detail-label" { "Channel Amount" }
+                        span class="detail-value-amount" { (format_sats_as_btc(ch.channel_value_sats)) }
+                    }
+                }
             }
-            form method="post" action="/channels/force-close" style="margin-top: 1rem;" {
+
+            form method="post" action="/channels/force-close" style="margin-top: 1rem; display: flex; justify-content: space-between; align-items: center;" {
                 input type="hidden" name="channel_id" value=(channel_id) {}
                 input type="hidden" name="node_id" value=(node_id) {}
-                button type="submit" style="background: #d63384;" { "Force Close Channel" }
-                " "
-                a href="/balance" { button type="button" { "Cancel" } }
+                a href="/balance" { button type="button" class="button-secondary" { "Cancel" } }
+                button type="submit" class="button-destructive" { "Force Close Channel" }
             }
         },
     );

+ 103 - 54
crates/cdk-ldk-node/src/web/handlers/lightning.rs

@@ -3,7 +3,7 @@ use axum::http::StatusCode;
 use axum::response::Html;
 use maud::html;
 
-use crate::web::handlers::AppState;
+use crate::web::handlers::utils::AppState;
 use crate::web::templates::{format_sats_as_btc, layout};
 
 pub async fn balance_page(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
@@ -26,18 +26,35 @@ pub async fn balance_page(State(state): State<AppState>) -> Result<Html<String>,
         html! {
             h2 style="text-align: center; margin-bottom: 3rem;" { "Lightning" }
 
-            // Quick Actions section - matching dashboard style
+            // Quick Actions section - individual cards
             div class="card" style="margin-bottom: 2rem;" {
                 h2 { "Quick Actions" }
-                div style="display: flex; gap: 1rem; margin-top: 1rem; flex-wrap: wrap;" {
-                    a href="/channels/open" style="text-decoration: none; flex: 1; min-width: 200px;" {
-                        button class="button-primary" style="width: 100%;" { "Open Channel" }
+                div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
+                    // Open Channel Card
+                    div class="quick-action-card" {
+                        h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Open Channel" }
+                        p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Create a new Lightning Network channel to connect with another node." }
+                        a href="/channels/open" style="text-decoration: none;" {
+                            button class="button-outline" { "Open Channel" }
+                        }
                     }
-                    a href="/invoices" style="text-decoration: none; flex: 1; min-width: 200px;" {
-                        button class="button-primary" style="width: 100%;" { "Create Invoice" }
+
+                    // Create Invoice Card
+                    div class="quick-action-card" {
+                        h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Create Invoice" }
+                        p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Generate a Lightning invoice to receive payments from other users or services." }
+                        a href="/invoices" style="text-decoration: none;" {
+                            button class="button-outline" { "Create Invoice" }
+                        }
                     }
-                    a href="/payments/send" style="text-decoration: none; flex: 1; min-width: 200px;" {
-                        button class="button-primary" style="width: 100%;" { "Make Lightning Payment" }
+
+                    // Make Payment Card
+                    div class="quick-action-card" {
+                        h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Make Lightning Payment" }
+                        p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Send Lightning payments to other users using invoices. BOLT 11 & 12 supported." }
+                        a href="/invoices" style="text-decoration: none;" {
+                            button class="button-outline" { "Make Payment" }
+                        }
                     }
                 }
             }
@@ -73,18 +90,35 @@ pub async fn balance_page(State(state): State<AppState>) -> Result<Html<String>,
         html! {
             h2 style="text-align: center; margin-bottom: 3rem;" { "Lightning" }
 
-            // Quick Actions section - matching dashboard style
+            // Quick Actions section - individual cards
             div class="card" style="margin-bottom: 2rem;" {
                 h2 { "Quick Actions" }
-                div style="display: flex; gap: 1rem; margin-top: 1rem; flex-wrap: wrap;" {
-                    a href="/channels/open" style="text-decoration: none; flex: 1; min-width: 200px;" {
-                        button class="button-primary" style="width: 100%;" { "Open Channel" }
+                div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
+                    // Open Channel Card
+                    div class="quick-action-card" {
+                        h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Open Channel" }
+                        p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Create a new Lightning channel by connecting with another node." }
+                        a href="/channels/open" style="text-decoration: none;" {
+                            button class="button-outline" { "Open Channel" }
+                        }
                     }
-                    a href="/invoices" style="text-decoration: none; flex: 1; min-width: 200px;" {
-                        button class="button-primary" style="width: 100%;" { "Create Invoice" }
+
+                    // Create Invoice Card
+                    div class="quick-action-card" {
+                        h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Create Invoice" }
+                        p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Generate a Lightning invoice to receive payments." }
+                        a href="/invoices" style="text-decoration: none;" {
+                            button class="button-outline" { "Create Invoice" }
+                        }
                     }
-                    a href="/payments/send" style="text-decoration: none; flex: 1; min-width: 200px;" {
-                        button class="button-primary" style="width: 100%;" { "Make Lightning Payment" }
+
+                    // Make Payment Card
+                    div class="quick-action-card" {
+                        h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Make Lightning Payment" }
+                        p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Send Lightning payments to other users using invoices." }
+                        a href="/payments/send" style="text-decoration: none;" {
+                            button class="button-outline" { "Make Payment" }
+                        }
                     }
                 }
             }
@@ -112,57 +146,72 @@ pub async fn balance_page(State(state): State<AppState>) -> Result<Html<String>,
                 }
             }
 
-            div class="card" {
-                h2 { "Channel Details" }
+            // Channel Details header (outside card)
+            h2 class="section-header" { "Channel Details" }
+
+            // Channels list
+            @for (index, channel) in channels.iter().enumerate() {
+                @let node_id = channel.counterparty_node_id.to_string();
+                @let channel_number = index + 1;
 
-                // Channels list
-                @for channel in &channels {
-                    div class="channel-item" {
-                        div class="channel-header" {
-                            span class="channel-id" { "Channel ID: " (channel.channel_id.to_string()) }
+                div class="channel-box" {
+                    // Channel number as prominent header
+                    div class="channel-alias" { (format!("Channel {}", channel_number)) }
+
+                    // Channel details in left-aligned format
+                    div class="channel-details" {
+                        div class="detail-row" {
+                            span class="detail-label" { "Channel ID" }
+                            span class="detail-value-amount" { (channel.channel_id.to_string()) }
+                        }
+                        @if let Some(short_channel_id) = channel.short_channel_id {
+                            div class="detail-row" {
+                                span class="detail-label" { "Short Channel ID" }
+                                span class="detail-value-amount" { (short_channel_id.to_string()) }
+                            }
+                        }
+                        div class="detail-row" {
+                            span class="detail-label" { "Node ID" }
+                            span class="detail-value-amount" { (node_id) }
+                        }
+                        div class="detail-row" {
+                            span class="detail-label" { "Status" }
                             @if channel.is_usable {
                                 span class="status-badge status-active" { "Active" }
                             } @else {
                                 span class="status-badge status-inactive" { "Inactive" }
                             }
                         }
-                        div class="info-item" {
-                            span class="info-label" { "Counterparty" }
-                            span class="info-value" style="font-family: monospace; font-size: 0.85rem;" { (channel.counterparty_node_id.to_string()) }
+                    }
+
+                    // Balance information cards (keeping existing style)
+                    div class="balance-info" {
+                        div class="balance-item" {
+                            div class="balance-amount" { (format_sats_as_btc(channel.outbound_capacity_msat / 1000)) }
+                            div class="balance-label" { "Outbound" }
                         }
-                        @if let Some(short_channel_id) = channel.short_channel_id {
-                            div class="info-item" {
-                                span class="info-label" { "Short Channel ID" }
-                                span class="info-value" { (short_channel_id.to_string()) }
-                            }
+                        div class="balance-item" {
+                            div class="balance-amount" { (format_sats_as_btc(channel.inbound_capacity_msat / 1000)) }
+                            div class="balance-label" { "Inbound" }
                         }
-                        div class="balance-info" {
-                            div class="balance-item" {
-                                div class="balance-amount" { (format_sats_as_btc(channel.outbound_capacity_msat / 1000)) }
-                                div class="balance-label" { "Outbound" }
-                            }
-                            div class="balance-item" {
-                                div class="balance-amount" { (format_sats_as_btc(channel.inbound_capacity_msat / 1000)) }
-                                div class="balance-label" { "Inbound" }
-                            }
-                            div class="balance-item" {
-                                div class="balance-amount" { (format_sats_as_btc(channel.channel_value_sats)) }
-                                div class="balance-label" { "Total" }
-                            }
+                        div class="balance-item" {
+                            div class="balance-amount" { (format_sats_as_btc(channel.channel_value_sats)) }
+                            div class="balance-label" { "Total" }
                         }
-                        @if channel.is_usable {
-                            div style="margin-top: 1rem; display: flex; gap: 0.5rem;" {
-                                a href=(format!("/channels/close?channel_id={}&node_id={}", channel.user_channel_id.0, channel.counterparty_node_id)) {
-                                    button style="background: #dc3545;" { "Close Channel" }
-                                }
-                                a href=(format!("/channels/force-close?channel_id={}&node_id={}", channel.user_channel_id.0, channel.counterparty_node_id)) {
-                                    button style="background: #d63384;" title="Force close should not be used if normal close is preferred. Force close will broadcast the latest commitment transaction immediately." { "Force Close" }
-                                }
+                    }
+
+                    // Action buttons
+                    @if channel.is_usable {
+                        div class="channel-actions" {
+                            a href=(format!("/channels/close?channel_id={}&node_id={}", channel.user_channel_id.0, channel.counterparty_node_id)) {
+                                button class="button-secondary" { "Close Channel" }
+                            }
+                            a href=(format!("/channels/force-close?channel_id={}&node_id={}", channel.user_channel_id.0, channel.counterparty_node_id)) {
+                                button class="button-destructive" title="Force close should not be used if normal close is preferred. Force close will broadcast the latest commitment transaction immediately." { "Force Close" }
                             }
                         }
                     }
                 }
-
             }
         }
     };

+ 54 - 21
crates/cdk-ldk-node/src/web/handlers/onchain.rs

@@ -79,15 +79,26 @@ pub async fn onchain_page(
     let mut content = html! {
         h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" }
 
-        // Quick Actions section - matching dashboard style
+        // Quick Actions section - individual cards
         div class="card" style="margin-bottom: 2rem;" {
             h2 { "Quick Actions" }
-            div style="display: flex; gap: 1rem; margin-top: 1rem; flex-wrap: wrap;" {
-                a href="/onchain?action=receive" style="text-decoration: none; flex: 1; min-width: 200px;" {
-                    button class="button-primary" style="width: 100%;" { "Receive Bitcoin" }
+            div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
+                // Receive Bitcoin Card
+                div class="quick-action-card" {
+                    h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Receive Bitcoin" }
+                    p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Generate a new Bitcoin address to receive on-chain payments from other users or services." }
+                    a href="/onchain?action=receive" style="text-decoration: none;" {
+                        button class="button-outline" { "Receive Bitcoin" }
+                    }
                 }
-                a href="/onchain?action=send" style="text-decoration: none; flex: 1; min-width: 200px;" {
-                    button class="button-primary" style="width: 100%;" { "Send Bitcoin" }
+
+                // Send Bitcoin Card
+                div class="quick-action-card" {
+                    h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Send Bitcoin" }
+                    p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Send Bitcoin to another address on the blockchain. Standard on-chain transactions." }
+                    a href="/onchain?action=send" style="text-decoration: none;" {
+                        button class="button-outline" { "Send Bitcoin" }
+                    }
                 }
             }
         }
@@ -113,15 +124,26 @@ pub async fn onchain_page(
             content = html! {
                 h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" }
 
-                // Quick Actions section - matching dashboard style
+                // Quick Actions section - individual cards
                 div class="card" style="margin-bottom: 2rem;" {
                     h2 { "Quick Actions" }
-                    div style="display: flex; gap: 1rem; margin-top: 1rem; flex-wrap: wrap;" {
-                        a href="/onchain?action=receive" style="text-decoration: none; flex: 1; min-width: 200px;" {
-                            button class="button-primary" style="width: 100%;" { "Receive Bitcoin" }
+                    div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
+                        // Receive Bitcoin Card
+                        div class="quick-action-card" {
+                            h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Receive Bitcoin" }
+                            p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Generate a new Bitcoin address to receive on-chain payments from other users or services." }
+                            a href="/onchain?action=receive" style="text-decoration: none;" {
+                                button class="button-outline" { "Receive Bitcoin" }
+                            }
                         }
-                        a href="/onchain?action=send" style="text-decoration: none; flex: 1; min-width: 200px;" {
-                            button class="button-primary" style="width: 100%;" { "Send Bitcoin" }
+
+                        // Send Bitcoin Card
+                        div class="quick-action-card" {
+                            h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Send Bitcoin" }
+                            p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Send Bitcoin to another address on the blockchain. Standard on-chain transactions." }
+                            a href="/onchain?action=send" style="text-decoration: none;" {
+                                button class="button-outline" { "Send Bitcoin" }
+                            }
                         }
                     }
                 }
@@ -141,7 +163,7 @@ pub async fn onchain_page(
                             }
                             input type="hidden" id="send_action" name="send_action" value="send" {}
                             div style="display: flex; justify-content: space-between; gap: 1rem; margin-top: 2rem;" {
-                                a href="/onchain" { button type="button" { "Cancel" } }
+                                a href="/onchain" { button type="button" class="button-secondary" { "Cancel" } }
                                 div style="display: flex; gap: 0.5rem;" {
                                     button type="submit" onclick="document.getElementById('send_action').value='send'" { "Send Payment" }
                                     button type="submit" onclick="document.getElementById('send_action').value='send_all'; document.getElementById('amount_sat').value=''" { "Send All" }
@@ -171,15 +193,26 @@ pub async fn onchain_page(
             content = html! {
                 h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" }
 
-                // Quick Actions section - matching dashboard style
+                // Quick Actions section - individual cards
                 div class="card" style="margin-bottom: 2rem;" {
                     h2 { "Quick Actions" }
-                    div style="display: flex; gap: 1rem; margin-top: 1rem; flex-wrap: wrap;" {
-                        a href="/onchain?action=receive" style="text-decoration: none; flex: 1; min-width: 200px;" {
-                            button class="button-primary" style="width: 100%;" { "Receive Bitcoin" }
+                    div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
+                        // Receive Bitcoin Card
+                        div class="quick-action-card" {
+                            h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Receive Bitcoin" }
+                            p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Generate a new Bitcoin address to receive on-chain payments from other users or services." }
+                            a href="/onchain?action=receive" style="text-decoration: none;" {
+                                button class="button-outline" { "Receive Bitcoin" }
+                            }
                         }
-                        a href="/onchain?action=send" style="text-decoration: none; flex: 1; min-width: 200px;" {
-                            button class="button-primary" style="width: 100%;" { "Send Bitcoin" }
+
+                        // Send Bitcoin Card
+                        div class="quick-action-card" {
+                            h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Send Bitcoin" }
+                            p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Send Bitcoin to another address on the blockchain. Standard on-chain transactions." }
+                            a href="/onchain?action=send" style="text-decoration: none;" {
+                                button class="button-outline" { "Send Bitcoin" }
+                            }
                         }
                     }
                 }
@@ -191,7 +224,7 @@ pub async fn onchain_page(
                         form method="post" action="/onchain/new-address" {
                             p style="margin-bottom: 2rem;" { "Click the button below to generate a new Bitcoin address for receiving on-chain payments." }
                             div style="display: flex; justify-content: space-between; gap: 1rem;" {
-                                a href="/onchain" { button type="button" { "Cancel" } }
+                                a href="/onchain" { button type="button" class="button-secondary" { "Cancel" } }
                                 button class="button-primary" type="submit" { "Generate New Address" }
                             }
                         }
@@ -345,7 +378,7 @@ pub async fn onchain_confirm_page(
         div class="card" {
             div style="display: flex; justify-content: space-between; gap: 1rem; margin-top: 2rem;" {
                 a href="/onchain?action=send" {
-                    button type="button" class="button-secondary" { "Cancel" }
+                    button type="button" class="button-secondary" { "Cancel" }
                 }
                 div style="display: flex; gap: 0.5rem;" {
                     a href=(confirmation_url) {

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 447 - 144
crates/cdk-ldk-node/src/web/templates/layout.rs


+ 1 - 1
crates/cdk-ldk-node/src/web/templates/payments.rs

@@ -17,7 +17,7 @@ pub fn payment_list_item(
     let status_class = match status {
         "Succeeded" => "status-active",
         "Failed" => "status-inactive",
-        "Pending" => "status-badge",
+        "Pending" => "status-pending",
         _ => "status-badge",
     };
 

BIN=BIN
crates/cdk-ldk-node/static/images/bg-dark.jpg


+ 4 - 4
crates/cdk-lnbits/src/lib.rs

@@ -15,7 +15,7 @@ use cdk_common::amount::{to_unit, Amount, MSAT_IN_SAT};
 use cdk_common::common::FeeReserve;
 use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState};
 use cdk_common::payment::{
-    self, Bolt11Settings, CreateIncomingPaymentResponse, IncomingPaymentOptions,
+    self, Bolt11Settings, CreateIncomingPaymentResponse, Event, IncomingPaymentOptions,
     MakePaymentResponse, MintPayment, OutgoingPaymentOptions, PaymentIdentifier,
     PaymentQuoteResponse, WaitPaymentResponse,
 };
@@ -155,9 +155,9 @@ impl MintPayment for LNbits {
         self.wait_invoice_cancel_token.cancel()
     }
 
-    async fn wait_any_incoming_payment(
+    async fn wait_payment_event(
         &self,
-    ) -> Result<Pin<Box<dyn Stream<Item = WaitPaymentResponse> + Send>>, Self::Err> {
+    ) -> Result<Pin<Box<dyn Stream<Item = Event> + Send>>, Self::Err> {
         let api = self.lnbits_api.clone();
         let cancel_token = self.wait_invoice_cancel_token.clone();
         let is_active = Arc::clone(&self.wait_invoice_is_active);
@@ -179,7 +179,7 @@ impl MintPayment for LNbits {
                     msg_option = receiver.recv() => {
                         Self::process_message(msg_option, &api, &is_active)
                             .await
-                            .map(|response| (response, (api, cancel_token, is_active)))
+                            .map(|response| (Event::PaymentReceived(response), (api, cancel_token, is_active)))
                     }
                 }
             },

+ 5 - 4
crates/cdk-lnd/src/lib.rs

@@ -20,7 +20,7 @@ use cdk_common::bitcoin::hashes::Hash;
 use cdk_common::common::FeeReserve;
 use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState};
 use cdk_common::payment::{
-    self, Bolt11Settings, CreateIncomingPaymentResponse, IncomingPaymentOptions,
+    self, Bolt11Settings, CreateIncomingPaymentResponse, Event, IncomingPaymentOptions,
     MakePaymentResponse, MintPayment, OutgoingPaymentOptions, PaymentIdentifier,
     PaymentQuoteResponse, WaitPaymentResponse,
 };
@@ -137,9 +137,9 @@ impl MintPayment for Lnd {
     }
 
     #[instrument(skip_all)]
-    async fn wait_any_incoming_payment(
+    async fn wait_payment_event(
         &self,
-    ) -> Result<Pin<Box<dyn Stream<Item = WaitPaymentResponse> + Send>>, Self::Err> {
+    ) -> Result<Pin<Box<dyn Stream<Item = Event> + Send>>, Self::Err> {
         let mut lnd_client = self.lnd_client.clone();
 
         let stream_req = lnrpc::InvoiceSubscription {
@@ -195,7 +195,8 @@ impl MintPayment for Lnd {
                                             };
                                             tracing::info!("LND: Created WaitPaymentResponse with amount {} msat", 
                                                          msg.amt_paid_msat);
-                                            Some((wait_response, (stream, cancel_token, is_active)))
+                                            let event = Event::PaymentReceived(wait_response);
+                                            Some((event, (stream, cancel_token, is_active)))
                             }  else { None }
                         } else {
                             None

+ 5 - 3
crates/cdk-payment-processor/src/proto/client.rs

@@ -263,9 +263,9 @@ impl MintPayment for PaymentProcessorClient {
     }
 
     #[instrument(skip_all)]
-    async fn wait_any_incoming_payment(
+    async fn wait_payment_event(
         &self,
-    ) -> Result<Pin<Box<dyn Stream<Item = WaitPaymentResponse> + Send>>, Self::Err> {
+    ) -> Result<Pin<Box<dyn Stream<Item = cdk_common::payment::Event> + Send>>, Self::Err> {
         self.wait_incoming_payment_stream_is_active
             .store(true, Ordering::SeqCst);
         tracing::debug!("Client waiting for payment");
@@ -288,7 +288,9 @@ impl MintPayment for PaymentProcessorClient {
             .filter_map(|item| async {
                 match item {
                     Ok(value) => match value.try_into() {
-                        Ok(payment_response) => Some(payment_response),
+                        Ok(payment_response) => Some(cdk_common::payment::Event::PaymentReceived(
+                            payment_response,
+                        )),
                         Err(e) => {
                             tracing::error!("Error converting payment response: {}", e);
                             None

+ 15 - 11
crates/cdk-payment-processor/src/proto/server.rs

@@ -401,19 +401,23 @@ impl CdkPaymentProcessor for PaymentProcessorServer {
                         ln.cancel_wait_invoice();
                         break;
                     }
-                    result = ln.wait_any_incoming_payment() => {
+                    result = ln.wait_payment_event() => {
                         match result {
                             Ok(mut stream) => {
-                                while let Some(payment_response) = stream.next().await {
-                                    match tx.send(Result::<_, Status>::Ok(payment_response.into()))
-                                    .await
-                                    {
-                                        Ok(_) => {
-                                            // Response was queued to be sent to client
-                                        }
-                                        Err(item) => {
-                                            tracing::error!("Error adding incoming payment to stream: {}", item);
-                                            break;
+                                while let Some(event) = stream.next().await {
+                                    match event {
+                                        cdk_common::payment::Event::PaymentReceived(payment_response) => {
+                                            match tx.send(Result::<_, Status>::Ok(payment_response.into()))
+                                            .await
+                                            {
+                                                Ok(_) => {
+                                                    // Response was queued to be sent to client
+                                                }
+                                                Err(item) => {
+                                                    tracing::error!("Error adding incoming payment to stream: {}", item);
+                                                    break;
+                                                }
+                                            }
                                         }
                                     }
                                 }

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

@@ -8,7 +8,6 @@ license.workspace = true
 homepage = "https://github.com/cashubtc/cdk"
 repository = "https://github.com/cashubtc/cdk.git"
 rust-version.workspace = true                            # MSRV
-readme = "README.md"
 
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 [features]
@@ -32,4 +31,5 @@ uuid.workspace = true
 tokio-postgres = "0.7.13"
 futures-util = "0.3.31"
 postgres-native-tls = "0.5.1"
+native-tls = "0.2"
 once_cell.workspace = true

+ 44 - 3
crates/cdk-postgres/src/lib.rs

@@ -10,6 +10,8 @@ use cdk_sql_common::pool::{DatabaseConfig, DatabasePool};
 use cdk_sql_common::stmt::{Column, Statement};
 use cdk_sql_common::{SQLMintDatabase, SQLWalletDatabase};
 use db::{pg_batch, pg_execute, pg_fetch_all, pg_fetch_one, pg_pluck};
+use native_tls::TlsConnector;
+use postgres_native_tls::MakeTlsConnector;
 use tokio::sync::{Mutex, Notify};
 use tokio::time::timeout;
 use tokio_postgres::{connect, Client, Error as PgError, NoTls};
@@ -25,6 +27,11 @@ pub enum SslMode {
     NoTls(NoTls),
     NativeTls(postgres_native_tls::MakeTlsConnector),
 }
+const SSLMODE_VERIFY_FULL: &str = "sslmode=verify-full";
+const SSLMODE_VERIFY_CA: &str = "sslmode=verify-ca";
+const SSLMODE_PREFER: &str = "sslmode=prefer";
+const SSLMODE_ALLOW: &str = "sslmode=allow";
+const SSLMODE_REQUIRE: &str = "sslmode=require";
 
 impl Default for SslMode {
     fn default() -> Self {
@@ -61,10 +68,44 @@ impl DatabaseConfig for PgConfig {
 }
 
 impl From<&str> for PgConfig {
-    fn from(value: &str) -> Self {
+    fn from(conn_str: &str) -> Self {
+        fn build_tls(accept_invalid_certs: bool, accept_invalid_hostnames: bool) -> SslMode {
+            let mut builder = TlsConnector::builder();
+            if accept_invalid_certs {
+                builder.danger_accept_invalid_certs(true);
+            }
+            if accept_invalid_hostnames {
+                builder.danger_accept_invalid_hostnames(true);
+            }
+
+            match builder.build() {
+                Ok(connector) => {
+                    let make_tls_connector = MakeTlsConnector::new(connector);
+                    SslMode::NativeTls(make_tls_connector)
+                }
+                Err(_) => SslMode::NoTls(NoTls {}),
+            }
+        }
+
+        let tls = if conn_str.contains(SSLMODE_VERIFY_FULL) {
+            // Strict TLS: valid certs and hostnames required
+            build_tls(false, false)
+        } else if conn_str.contains(SSLMODE_VERIFY_CA) {
+            // Verify CA, but allow invalid hostnames
+            build_tls(false, true)
+        } else if conn_str.contains(SSLMODE_PREFER)
+            || conn_str.contains(SSLMODE_ALLOW)
+            || conn_str.contains(SSLMODE_REQUIRE)
+        {
+            // Lenient TLS for preferred/allow/require: accept invalid certs and hostnames
+            build_tls(true, true)
+        } else {
+            SslMode::NoTls(NoTls {})
+        };
+
         PgConfig {
-            url: value.to_owned(),
-            tls: Default::default(),
+            url: conn_str.to_owned(),
+            tls,
         }
     }
 }

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

@@ -27,5 +27,4 @@ tokio.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 lightning-invoice.workspace = true
-uuid.workspace = true
 once_cell.workspace = true

+ 29 - 123
crates/cdk-sql-common/src/mint/mod.rs

@@ -37,7 +37,6 @@ use cdk_common::{
 use lightning_invoice::Bolt11Invoice;
 use migrations::MIGRATIONS;
 use tracing::instrument;
-use uuid::Uuid;
 
 use crate::common::migrate;
 use crate::database::{ConnectionWithTransaction, DatabaseExecutor};
@@ -170,7 +169,7 @@ where
     async fn add_proofs(
         &mut self,
         proofs: Proofs,
-        quote_id: Option<Uuid>,
+        quote_id: Option<QuoteId>,
     ) -> Result<(), Self::Err> {
         let current_time = unix_time();
 
@@ -213,7 +212,7 @@ where
                 proof.witness.map(|w| serde_json::to_string(&w).unwrap()),
             )
             .bind("state", "UNSPENT".to_string())
-            .bind("quote_id", quote_id.map(|q| q.hyphenated().to_string()))
+            .bind("quote_id", quote_id.clone().map(|q| q.to_string()))
             .bind("created_time", current_time as i64)
             .execute(&self.inner)
             .await?;
@@ -254,7 +253,7 @@ where
     async fn remove_proofs(
         &mut self,
         ys: &[PublicKey],
-        _quote_id: Option<Uuid>,
+        _quote_id: Option<QuoteId>,
     ) -> Result<(), Self::Err> {
         if ys.is_empty() {
             return Ok(());
@@ -328,13 +327,7 @@ where
             quote_id=:quote_id
         "#,
     )?
-    .bind(
-        "quote_id",
-        match quote_id {
-            QuoteId::UUID(u) => u.as_hyphenated().to_string(),
-            QuoteId::BASE64(s) => s.to_string(),
-        },
-    )
+    .bind("quote_id", quote_id.to_string())
     .fetch_all(conn)
     .await?
     .into_iter()
@@ -363,13 +356,7 @@ FROM mint_quote_issued
 WHERE quote_id=:quote_id
             "#,
     )?
-    .bind(
-        "quote_id",
-        match quote_id {
-            QuoteId::UUID(u) => u.as_hyphenated().to_string(),
-            QuoteId::BASE64(s) => s.to_string(),
-        },
-    )
+    .bind("quote_id", quote_id.to_string())
     .fetch_all(conn)
     .await?
     .into_iter()
@@ -591,13 +578,7 @@ where
             FOR UPDATE
             "#,
         )?
-        .bind(
-            "quote_id",
-            match quote_id {
-                QuoteId::UUID(u) => u.as_hyphenated().to_string(),
-                QuoteId::BASE64(s) => s.to_string(),
-            },
-        )
+        .bind("quote_id", quote_id.to_string())
         .fetch_one(&self.inner)
         .await
         .inspect_err(|err| {
@@ -632,13 +613,7 @@ where
             "#,
         )?
         .bind("amount_paid", new_amount_paid.to_i64())
-        .bind(
-            "quote_id",
-            match quote_id {
-                QuoteId::UUID(u) => u.as_hyphenated().to_string(),
-                QuoteId::BASE64(s) => s.to_string(),
-            },
-        )
+        .bind("quote_id", quote_id.to_string())
         .execute(&self.inner)
         .await
         .inspect_err(|err| {
@@ -653,13 +628,7 @@ where
             VALUES (:quote_id, :payment_id, :amount, :timestamp)
             "#,
         )?
-        .bind(
-            "quote_id",
-            match quote_id {
-                QuoteId::UUID(u) => u.as_hyphenated().to_string(),
-                QuoteId::BASE64(s) => s.to_string(),
-            },
-        )
+        .bind("quote_id", quote_id.to_string())
         .bind("payment_id", payment_id)
         .bind("amount", amount_paid.to_i64())
         .bind("timestamp", unix_time() as i64)
@@ -688,13 +657,7 @@ where
             FOR UPDATE
             "#,
         )?
-        .bind(
-            "quote_id",
-            match quote_id {
-                QuoteId::UUID(u) => u.as_hyphenated().to_string(),
-                QuoteId::BASE64(s) => s.to_string(),
-            },
-        )
+        .bind("quote_id", quote_id.to_string())
         .fetch_one(&self.inner)
         .await
         .inspect_err(|err| {
@@ -722,13 +685,7 @@ where
             "#,
         )?
         .bind("amount_issued", new_amount_issued.to_i64())
-        .bind(
-            "quote_id",
-            match quote_id {
-                QuoteId::UUID(u) => u.as_hyphenated().to_string(),
-                QuoteId::BASE64(s) => s.to_string(),
-            },
-        )
+        .bind("quote_id", quote_id.to_string())
         .execute(&self.inner)
         .await
         .inspect_err(|err| {
@@ -744,13 +701,7 @@ INSERT INTO mint_quote_issued
 VALUES (:quote_id, :amount, :timestamp);
             "#,
         )?
-        .bind(
-            "quote_id",
-            match quote_id {
-                QuoteId::UUID(u) => u.as_hyphenated().to_string(),
-                QuoteId::BASE64(s) => s.to_string(),
-            },
-        )
+        .bind("quote_id", quote_id.to_string())
         .bind("amount", amount_issued.to_i64())
         .bind("timestamp", current_time as i64)
         .execute(&self.inner)
@@ -792,13 +743,7 @@ VALUES (:quote_id, :amount, :timestamp);
 
     async fn remove_mint_quote(&mut self, quote_id: &QuoteId) -> Result<(), Self::Err> {
         query(r#"DELETE FROM mint_quote WHERE id=:id"#)?
-            .bind(
-                "id",
-                match quote_id {
-                    QuoteId::UUID(u) => u.as_hyphenated().to_string(),
-                    QuoteId::BASE64(s) => s.to_string(),
-                },
-            )
+            .bind("id", quote_id.to_string())
             .execute(&self.inner)
             .await?;
         Ok(())
@@ -861,10 +806,7 @@ VALUES (:quote_id, :amount, :timestamp);
         query(r#"UPDATE melt_quote SET request_lookup_id = :new_req_id, request_lookup_id_kind = :new_kind WHERE id = :id"#)?
             .bind("new_req_id", new_request_lookup_id.to_string())
             .bind("new_kind",new_request_lookup_id.kind() )
-            .bind("id", match quote_id {
-                QuoteId::BASE64(s) => s.to_string(),
-                QuoteId::UUID(u) => u.as_hyphenated().to_string(),
-            })
+            .bind("id", quote_id.to_string())
             .execute(&self.inner)
             .await?;
         Ok(())
@@ -900,13 +842,7 @@ VALUES (:quote_id, :amount, :timestamp);
                 AND state != :state
             "#,
         )?
-        .bind(
-            "id",
-            match quote_id {
-                QuoteId::UUID(u) => u.as_hyphenated().to_string(),
-                QuoteId::BASE64(s) => s.to_string(),
-            },
-        )
+        .bind("id", quote_id.to_string())
         .bind("state", state.to_string())
         .fetch_one(&self.inner)
         .await?
@@ -920,22 +856,13 @@ VALUES (:quote_id, :amount, :timestamp);
                 .bind("state", state.to_string())
                 .bind("paid_time", current_time as i64)
                 .bind("payment_preimage", payment_proof)
-                .bind("id", match quote_id {
-                    QuoteId::UUID(u) => u.as_hyphenated().to_string(),
-                    QuoteId::BASE64(s) => s.to_string(),
-                })
+                .bind("id", quote_id.to_string())
                 .execute(&self.inner)
                 .await
         } else {
             query(r#"UPDATE melt_quote SET state = :state WHERE id = :id"#)?
                 .bind("state", state.to_string())
-                .bind(
-                    "id",
-                    match quote_id {
-                        QuoteId::UUID(u) => u.as_hyphenated().to_string(),
-                        QuoteId::BASE64(s) => s.to_string(),
-                    },
-                )
+                .bind("id", quote_id.to_string())
                 .execute(&self.inner)
                 .await
         };
@@ -954,14 +881,14 @@ VALUES (:quote_id, :amount, :timestamp);
         Ok((old_state, quote))
     }
 
-    async fn remove_melt_quote(&mut self, quote_id: &Uuid) -> Result<(), Self::Err> {
+    async fn remove_melt_quote(&mut self, quote_id: &QuoteId) -> Result<(), Self::Err> {
         query(
             r#"
             DELETE FROM melt_quote
             WHERE id=?
             "#,
         )?
-        .bind("id", quote_id.as_hyphenated().to_string())
+        .bind("id", quote_id.to_string())
         .execute(&self.inner)
         .await?;
 
@@ -993,13 +920,7 @@ VALUES (:quote_id, :amount, :timestamp);
             FOR UPDATE
             "#,
         )?
-        .bind(
-            "id",
-            match quote_id {
-                QuoteId::UUID(u) => u.as_hyphenated().to_string(),
-                QuoteId::BASE64(s) => s.to_string(),
-            },
-        )
+        .bind("id", quote_id.to_string())
         .fetch_one(&self.inner)
         .await?
         .map(|row| sql_row_to_mint_quote(row, payments, issuance))
@@ -1008,7 +929,7 @@ VALUES (:quote_id, :amount, :timestamp);
 
     async fn get_melt_quote(
         &mut self,
-        quote_id: &Uuid,
+        quote_id: &QuoteId,
     ) -> Result<Option<mint::MeltQuote>, Self::Err> {
         Ok(query(
             r#"
@@ -1033,7 +954,7 @@ VALUES (:quote_id, :amount, :timestamp);
                 id=:id
             "#,
         )?
-        .bind("id", quote_id.as_hyphenated().to_string())
+        .bind("id", quote_id.to_string())
         .fetch_one(&self.inner)
         .await?
         .map(sql_row_to_melt_quote)
@@ -1157,13 +1078,7 @@ where
                 mint_quote
             WHERE id = :id"#,
         )?
-        .bind(
-            "id",
-            match quote_id {
-                QuoteId::UUID(u) => u.as_hyphenated().to_string(),
-                QuoteId::BASE64(s) => s.to_string(),
-            },
-        )
+        .bind("id", quote_id.to_string())
         .fetch_one(&*conn)
         .await?
         .map(|row| sql_row_to_mint_quote(row, payments, issuance))
@@ -1319,13 +1234,7 @@ where
                 id=:id
             "#,
         )?
-        .bind(
-            "id",
-            match quote_id {
-                QuoteId::BASE64(s) => s.to_string(),
-                QuoteId::UUID(u) => u.as_hyphenated().to_string(),
-            },
-        )
+        .bind("id", quote_id.to_string())
         .fetch_one(&*conn)
         .await?
         .map(sql_row_to_melt_quote)
@@ -1406,7 +1315,10 @@ where
         Ok(ys.iter().map(|y| proofs.remove(y)).collect())
     }
 
-    async fn get_proof_ys_by_quote_id(&self, quote_id: &Uuid) -> Result<Vec<PublicKey>, Self::Err> {
+    async fn get_proof_ys_by_quote_id(
+        &self,
+        quote_id: &QuoteId,
+    ) -> Result<Vec<PublicKey>, Self::Err> {
         let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
         Ok(query(
             r#"
@@ -1422,7 +1334,7 @@ where
                 quote_id = :quote_id
             "#,
         )?
-        .bind("quote_id", quote_id.as_hyphenated().to_string())
+        .bind("quote_id", quote_id.to_string())
         .fetch_all(&*conn)
         .await?
         .into_iter()
@@ -1661,13 +1573,7 @@ where
                 quote_id=:quote_id
             "#,
         )?
-        .bind(
-            "quote_id",
-            match quote_id {
-                QuoteId::BASE64(s) => s.to_string(),
-                QuoteId::UUID(u) => u.as_hyphenated().to_string(),
-            },
-        )
+        .bind("quote_id", quote_id.to_string())
         .fetch_all(&*conn)
         .await?
         .into_iter()

+ 2 - 0
crates/cdk-sql-common/src/wallet/migrations.rs

@@ -2,6 +2,7 @@
 /// Auto-generated by build.rs
 pub static MIGRATIONS: &[(&str, &str, &str)] = &[
     ("postgres", "1_initial.sql", include_str!(r#"./migrations/postgres/1_initial.sql"#)),
+    ("postgres", "20250831215438_melt_quote_method.sql", include_str!(r#"./migrations/postgres/20250831215438_melt_quote_method.sql"#)),
     ("sqlite", "1_fix_sqlx_migration.sql", include_str!(r#"./migrations/sqlite/1_fix_sqlx_migration.sql"#)),
     ("sqlite", "20240612132920_init.sql", include_str!(r#"./migrations/sqlite/20240612132920_init.sql"#)),
     ("sqlite", "20240618200350_quote_state.sql", include_str!(r#"./migrations/sqlite/20240618200350_quote_state.sql"#)),
@@ -22,4 +23,5 @@ pub static MIGRATIONS: &[(&str, &str, &str)] = &[
     ("sqlite", "20250707093445_bolt12.sql", include_str!(r#"./migrations/sqlite/20250707093445_bolt12.sql"#)),
     ("sqlite", "20250729111701_keyset_v2_u32.sql", include_str!(r#"./migrations/sqlite/20250729111701_keyset_v2_u32.sql"#)),
     ("sqlite", "20250812084621_keyset_plus_one.sql", include_str!(r#"./migrations/sqlite/20250812084621_keyset_plus_one.sql"#)),
+    ("sqlite", "20250831215438_melt_quote_method.sql", include_str!(r#"./migrations/sqlite/20250831215438_melt_quote_method.sql"#)),
 ];

+ 1 - 0
crates/cdk-sql-common/src/wallet/migrations/postgres/20250831215438_melt_quote_method.sql

@@ -0,0 +1 @@
+ALTER TABLE melt_quote ADD COLUMN payment_method TEXT NOT NULL DEFAULT 'bolt11';

+ 1 - 0
crates/cdk-sql-common/src/wallet/migrations/sqlite/20250831215438_melt_quote_method.sql

@@ -0,0 +1 @@
+ALTER TABLE melt_quote ADD COLUMN payment_method TEXT NOT NULL DEFAULT 'bolt11';

+ 18 - 6
crates/cdk-sql-common/src/wallet/mod.rs

@@ -156,7 +156,8 @@ where
                   fee_reserve,
                   state,
                   expiry,
-                  payment_preimage
+                  payment_preimage,
+                  payment_method
               FROM
                   melt_quote
               "#,
@@ -551,6 +552,9 @@ ON CONFLICT(id) DO UPDATE SET
                 state,
                 expiry,
                 secret_key
+                payment_method,
+                amount_issued,
+                amount_paid
             FROM
                 mint_quote
             "#,
@@ -579,16 +583,17 @@ ON CONFLICT(id) DO UPDATE SET
         query(
             r#"
 INSERT INTO melt_quote
-(id, unit, amount, request, fee_reserve, state, expiry)
+(id, unit, amount, request, fee_reserve, state, expiry, payment_method)
 VALUES
-(:id, :unit, :amount, :request, :fee_reserve, :state, :expiry)
+(:id, :unit, :amount, :request, :fee_reserve, :state, :expiry, :payment_method)
 ON CONFLICT(id) DO UPDATE SET
     unit = excluded.unit,
     amount = excluded.amount,
     request = excluded.request,
     fee_reserve = excluded.fee_reserve,
     state = excluded.state,
-    expiry = excluded.expiry
+    expiry = excluded.expiry,
+    payment_method = excluded.payment_method
 ;
         "#,
         )?
@@ -599,6 +604,7 @@ ON CONFLICT(id) DO UPDATE SET
         .bind("fee_reserve", u64::from(quote.fee_reserve) as i64)
         .bind("state", quote.state.to_string())
         .bind("expiry", quote.expiry as i64)
+        .bind("payment_method", quote.payment_method.to_string())
         .execute(&*conn)
         .await?;
 
@@ -618,7 +624,8 @@ ON CONFLICT(id) DO UPDATE SET
                 fee_reserve,
                 state,
                 expiry,
-                payment_preimage
+                payment_preimage,
+                payment_method
             FROM
                 melt_quote
             WHERE
@@ -1124,13 +1131,17 @@ fn sql_row_to_melt_quote(row: Vec<Column>) -> Result<wallet::MeltQuote, Error> {
             fee_reserve,
             state,
             expiry,
-            payment_preimage
+            payment_preimage,
+            row_method
         ) = row
     );
 
     let amount: u64 = column_as_number!(amount);
     let fee_reserve: u64 = column_as_number!(fee_reserve);
 
+    let payment_method =
+        PaymentMethod::from_str(&column_as_string!(row_method)).map_err(Error::from)?;
+
     Ok(wallet::MeltQuote {
         id: column_as_string!(id),
         amount: Amount::from(amount),
@@ -1140,6 +1151,7 @@ fn sql_row_to_melt_quote(row: Vec<Column>) -> Result<wallet::MeltQuote, Error> {
         state: column_as_string!(state, MeltQuoteState::from_str),
         expiry: column_as_number!(expiry),
         payment_preimage: column_as_nullable_string!(payment_preimage),
+        payment_method,
     })
 }
 

+ 6 - 0
crates/cdk/Cargo.toml

@@ -106,6 +106,11 @@ required-features = ["wallet", "bip353"]
 [[example]]
 name = "mint-token-bolt12-with-stream"
 required-features = ["wallet"]
+
+[[example]]
+name = "mint-token-bolt12-with-custom-http"
+required-features = ["wallet"]
+
 [[example]]
 name = "mint-token-bolt12"
 required-features = ["wallet"]
@@ -118,6 +123,7 @@ tracing-subscriber.workspace = true
 criterion = "0.6.0"
 reqwest = { workspace = true }
 anyhow.workspace = true
+ureq = { version = "3.1.0", features = ["json"] }
 
 
 [[bench]]

+ 161 - 0
crates/cdk/examples/mint-token-bolt12-with-custom-http.rs

@@ -0,0 +1,161 @@
+use std::str::FromStr;
+use std::sync::Arc;
+use std::time::Duration;
+
+use cdk::error::Error;
+use cdk::nuts::nut00::ProofsMethods;
+use cdk::nuts::CurrencyUnit;
+use cdk::wallet::{BaseHttpClient, HttpTransport, SendOptions, WalletBuilder};
+use cdk::{Amount, StreamExt};
+use cdk_common::mint_url::MintUrl;
+use cdk_common::AuthToken;
+use cdk_sqlite::wallet::memory;
+use rand::random;
+use serde::de::DeserializeOwned;
+use serde::Serialize;
+use tracing_subscriber::EnvFilter;
+use ureq::config::Config;
+use ureq::Agent;
+use url::Url;
+
+#[derive(Debug, Clone)]
+pub struct CustomHttp {
+    agent: Agent,
+}
+
+impl Default for CustomHttp {
+    fn default() -> Self {
+        Self {
+            agent: Agent::new_with_config(
+                Config::builder()
+                    .timeout_global(Some(Duration::from_secs(5)))
+                    .no_delay(true)
+                    .user_agent("Custom HTTP Transport")
+                    .build(),
+            ),
+        }
+    }
+}
+
+#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
+#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
+impl HttpTransport for CustomHttp {
+    fn with_proxy(
+        &mut self,
+        _proxy: Url,
+        _host_matcher: Option<&str>,
+        _accept_invalid_certs: bool,
+    ) -> Result<(), Error> {
+        panic!("Not supported");
+    }
+
+    async fn http_get<R>(&self, url: Url, _auth: Option<AuthToken>) -> Result<R, Error>
+    where
+        R: DeserializeOwned,
+    {
+        self.agent
+            .get(url.as_str())
+            .call()
+            .map_err(|e| Error::HttpError(None, e.to_string()))?
+            .body_mut()
+            .read_json()
+            .map_err(|e| Error::HttpError(None, e.to_string()))
+    }
+
+    /// HTTP Post request
+    async fn http_post<P, R>(
+        &self,
+        url: Url,
+        _auth_token: Option<AuthToken>,
+        payload: &P,
+    ) -> Result<R, Error>
+    where
+        P: Serialize + ?Sized + Send + Sync,
+        R: DeserializeOwned,
+    {
+        self.agent
+            .post(url.as_str())
+            .send_json(payload)
+            .map_err(|e| Error::HttpError(None, e.to_string()))?
+            .body_mut()
+            .read_json()
+            .map_err(|e| Error::HttpError(None, e.to_string()))
+    }
+}
+
+type CustomConnector = BaseHttpClient<CustomHttp>;
+
+#[tokio::main]
+async fn main() -> Result<(), Error> {
+    let default_filter = "debug";
+
+    let sqlx_filter = "sqlx=warn,hyper_util=warn,reqwest=warn,rustls=warn";
+
+    let env_filter = EnvFilter::new(format!("{},{}", default_filter, sqlx_filter));
+
+    // Parse input
+    tracing_subscriber::fmt().with_env_filter(env_filter).init();
+
+    // Initialize the memory store for the wallet
+    let localstore = Arc::new(memory::empty().await?);
+
+    // Generate a random seed for the wallet
+    let seed = random::<[u8; 64]>();
+
+    // Define the mint URL and currency unit
+    let mint_url = "https://fake.thesimplekid.dev";
+    let unit = CurrencyUnit::Sat;
+    let amount = Amount::from(10);
+
+    let mint_url = MintUrl::from_str(mint_url)?;
+    #[cfg(feature = "auth")]
+    let http_client = CustomConnector::new(mint_url.clone(), None);
+
+    #[cfg(not(feature = "auth"))]
+    let http_client = CustomConnector::new(mint_url.clone());
+
+    // Create a new wallet
+    let wallet = WalletBuilder::new()
+        .mint_url(mint_url)
+        .unit(unit)
+        .localstore(localstore)
+        .seed(seed)
+        .target_proof_count(3)
+        .client(http_client)
+        .build()?;
+
+    let quotes = vec![
+        wallet.mint_bolt12_quote(None, None).await?,
+        wallet.mint_bolt12_quote(None, None).await?,
+        wallet.mint_bolt12_quote(None, None).await?,
+    ];
+
+    let mut stream = wallet.mints_proof_stream(quotes, Default::default(), None);
+
+    let stop = stream.get_cancel_token();
+
+    let mut processed = 0;
+
+    while let Some(proofs) = stream.next().await {
+        let (mint_quote, proofs) = proofs?;
+
+        // Mint the received amount
+        let receive_amount = proofs.total_amount()?;
+        tracing::info!("Received {} from mint {}", receive_amount, mint_quote.id);
+
+        // Send a token with the specified amount
+        let prepared_send = wallet.prepare_send(amount, SendOptions::default()).await?;
+        let token = prepared_send.confirm(None).await?;
+        tracing::info!("Token: {}", token);
+
+        processed += 1;
+
+        if processed == 3 {
+            stop.cancel()
+        }
+    }
+
+    tracing::info!("Stopped the loop after {} quotes being minted", processed);
+
+    Ok(())
+}

+ 12 - 8
crates/cdk/src/mint/mod.rs

@@ -536,16 +536,20 @@ impl Mint {
                     processor.cancel_wait_invoice();
                     break;
                 }
-                result = processor.wait_any_incoming_payment() => {
+                result = processor.wait_payment_event() => {
                     match result {
                         Ok(mut stream) => {
-                            while let Some(request_lookup_id) = stream.next().await {
-                                if let Err(e) = Self::handle_payment_notification(
-                                    &localstore,
-                                    &pubsub_manager,
-                                    request_lookup_id,
-                                ).await {
-                                    tracing::warn!("Payment notification error: {:?}", e);
+                            while let Some(event) = stream.next().await {
+                                match event {
+                                    cdk_common::payment::Event::PaymentReceived(wait_payment_response) => {
+                                        if let Err(e) = Self::handle_payment_notification(
+                                            &localstore,
+                                            &pubsub_manager,
+                                            wait_payment_response,
+                                        ).await {
+                                            tracing::warn!("Payment notification error: {:?}", e);
+                                        }
+                                    }
                                 }
                             }
                         }

+ 39 - 26
crates/cdk/src/pub_sub.rs

@@ -41,10 +41,10 @@ pub struct Manager<T, I, F>
 where
     T: Indexable<Type = I> + Clone + Send + Sync + 'static,
     I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static,
-    F: OnNewSubscription<Index = I, Event = T> + 'static,
+    F: OnNewSubscription<Index = I, Event = T> + Send + Sync + 'static,
 {
     indexes: IndexTree<T, I>,
-    on_new_subscription: Option<F>,
+    on_new_subscription: Option<Arc<F>>,
     unsubscription_sender: mpsc::Sender<(SubId, Vec<Index<I>>)>,
     active_subscriptions: Arc<AtomicUsize>,
     background_subscription_remover: Option<JoinHandle<()>>,
@@ -54,7 +54,7 @@ impl<T, I, F> Default for Manager<T, I, F>
 where
     T: Indexable<Type = I> + Clone + Send + Sync + 'static,
     I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static,
-    F: OnNewSubscription<Index = I, Event = T> + 'static,
+    F: OnNewSubscription<Index = I, Event = T> + Send + Sync + 'static,
 {
     fn default() -> Self {
         let (sender, receiver) = mpsc::channel(DEFAULT_REMOVE_SIZE);
@@ -79,11 +79,11 @@ impl<T, I, F> From<F> for Manager<T, I, F>
 where
     T: Indexable<Type = I> + Clone + Send + Sync + 'static,
     I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static,
-    F: OnNewSubscription<Index = I, Event = T> + 'static,
+    F: OnNewSubscription<Index = I, Event = T> + Send + Sync + 'static,
 {
     fn from(value: F) -> Self {
         let mut manager: Self = Default::default();
-        manager.on_new_subscription = Some(value);
+        manager.on_new_subscription = Some(Arc::new(value));
         manager
     }
 }
@@ -92,7 +92,7 @@ impl<T, I, F> Manager<T, I, F>
 where
     T: Indexable<Type = I> + Clone + Send + Sync + 'static,
     I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static,
-    F: OnNewSubscription<Index = I, Event = T> + 'static,
+    F: OnNewSubscription<Index = I, Event = T> + Send + Sync + 'static,
 {
     #[inline]
     /// Broadcast an event to all listeners
@@ -143,32 +143,45 @@ where
         indexes: Vec<Index<I>>,
     ) -> ActiveSubscription<T, I> {
         let (sender, receiver) = mpsc::channel(10);
-        if let Some(on_new_subscription) = self.on_new_subscription.as_ref() {
-            match on_new_subscription
-                .on_new_subscription(&indexes.iter().map(|x| x.deref()).collect::<Vec<_>>())
-                .await
-            {
-                Ok(events) => {
-                    for event in events {
-                        let _ = sender.try_send((sub_id.clone(), event));
-                    }
-                }
-                Err(err) => {
-                    tracing::info!(
-                        "Failed to get initial state for subscription: {:?}, {}",
-                        sub_id,
-                        err
-                    );
-                }
-            }
-        }
 
         let mut index_storage = self.indexes.write().await;
+        // Subscribe to events as soon as possible
         for index in indexes.clone() {
             index_storage.insert(index, sender.clone());
         }
         drop(index_storage);
 
+        if let Some(on_new_subscription) = self.on_new_subscription.clone() {
+            // After we're subscribed already, fetch the current status of matching events. It is
+            // down in another thread to return right away
+            let indexes_for_worker = indexes.clone();
+            let sub_id_for_worker = sub_id.clone();
+            tokio::spawn(async move {
+                match on_new_subscription
+                    .on_new_subscription(
+                        &indexes_for_worker
+                            .iter()
+                            .map(|x| x.deref())
+                            .collect::<Vec<_>>(),
+                    )
+                    .await
+                {
+                    Ok(events) => {
+                        for event in events {
+                            let _ = sender.try_send((sub_id_for_worker.clone(), event));
+                        }
+                    }
+                    Err(err) => {
+                        tracing::info!(
+                            "Failed to get initial state for subscription: {:?}, {}",
+                            sub_id_for_worker,
+                            err
+                        );
+                    }
+                }
+            });
+        }
+
         self.active_subscriptions
             .fetch_add(1, atomic::Ordering::Relaxed);
 
@@ -232,7 +245,7 @@ impl<T, I, F> Drop for Manager<T, I, F>
 where
     T: Indexable<Type = I> + Clone + Send + Sync + 'static,
     I: Clone + Debug + PartialOrd + Ord + Send + Sync + 'static,
-    F: OnNewSubscription<Index = I, Event = T> + 'static,
+    F: OnNewSubscription<Index = I, Event = T> + Send + Sync + 'static,
 {
     fn drop(&mut self) {
         if let Some(handler) = self.background_subscription_remover.take() {

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

@@ -26,6 +26,7 @@ pub struct WalletBuilder {
     #[cfg(feature = "auth")]
     auth_wallet: Option<AuthWallet>,
     seed: Option<[u8; 64]>,
+    use_http_subscription: bool,
     client: Option<Arc<dyn MintConnector + Send + Sync>>,
 }
 
@@ -40,6 +41,7 @@ impl Default for WalletBuilder {
             auth_wallet: None,
             seed: None,
             client: None,
+            use_http_subscription: false,
         }
     }
 }
@@ -50,6 +52,19 @@ impl WalletBuilder {
         Self::default()
     }
 
+    /// Use HTTP for wallet subscriptions to mint events
+    pub fn use_http_subscription(mut self) -> Self {
+        self.use_http_subscription = true;
+        self
+    }
+
+    /// If WS is preferred (with fallback to HTTP is it is not supported by the mint) for the wallet
+    /// subscriptions to mint events
+    pub fn prefer_ws_subscription(mut self) -> Self {
+        self.use_http_subscription = false;
+        self
+    }
+
     /// Set the mint URL
     pub fn mint_url(mut self, mint_url: MintUrl) -> Self {
         self.mint_url = Some(mint_url);
@@ -150,7 +165,7 @@ impl WalletBuilder {
             auth_wallet: Arc::new(RwLock::new(self.auth_wallet)),
             seed,
             client: client.clone(),
-            subscription: SubscriptionManager::new(client),
+            subscription: SubscriptionManager::new(client, self.use_http_subscription),
         })
     }
 }

+ 1 - 1
crates/cdk/src/wallet/issue/issue_bolt12.rs

@@ -1,7 +1,7 @@
 use std::collections::HashMap;
 
 use cdk_common::nut04::MintMethodOptions;
-use cdk_common::nut24::MintQuoteBolt12Request;
+use cdk_common::nut25::MintQuoteBolt12Request;
 use cdk_common::wallet::{Transaction, TransactionDirection};
 use cdk_common::{Proofs, SecretKey};
 use tracing::instrument;

+ 9 - 1
crates/cdk/src/wallet/melt/melt_bolt11.rs

@@ -3,6 +3,7 @@ use std::str::FromStr;
 
 use cdk_common::amount::SplitTarget;
 use cdk_common::wallet::{Transaction, TransactionDirection};
+use cdk_common::PaymentMethod;
 use lightning_invoice::Bolt11Invoice;
 use tracing::instrument;
 
@@ -87,6 +88,7 @@ impl Wallet {
             state: quote_res.state,
             expiry: quote_res.expiry,
             payment_preimage: quote_res.payment_preimage,
+            payment_method: PaymentMethod::Bolt11,
         };
 
         self.localstore.add_melt_quote(quote.clone()).await?;
@@ -183,7 +185,13 @@ impl Wallet {
             Some(premint_secrets.blinded_messages()),
         );
 
-        let melt_response = self.client.post_melt(request).await;
+        let melt_response = match quote_info.payment_method {
+            cdk_common::PaymentMethod::Bolt11 => self.client.post_melt(request).await,
+            cdk_common::PaymentMethod::Bolt12 => self.client.post_melt_bolt12(request).await,
+            cdk_common::PaymentMethod::Custom(_) => {
+                return Err(Error::UnsupportedPaymentMethod);
+            }
+        };
 
         let melt_response = match melt_response {
             Ok(melt_response) => melt_response,

+ 2 - 0
crates/cdk/src/wallet/melt/melt_bolt12.rs

@@ -6,6 +6,7 @@ use std::str::FromStr;
 
 use cdk_common::amount::amount_for_offer;
 use cdk_common::wallet::MeltQuote;
+use cdk_common::PaymentMethod;
 use lightning::offers::offer::Offer;
 use tracing::instrument;
 
@@ -57,6 +58,7 @@ impl Wallet {
             state: quote_res.state,
             expiry: quote_res.expiry,
             payment_preimage: quote_res.payment_preimage,
+            payment_method: PaymentMethod::Bolt12,
         };
 
         self.localstore.add_melt_quote(quote.clone()).await?;

+ 60 - 156
crates/cdk/src/wallet/mint_connector/http_client.rs

@@ -1,3 +1,4 @@
+//! HTTP Mint client with pluggable transport
 use std::collections::HashSet;
 use std::sync::{Arc, RwLock as StdRwLock};
 use std::time::{Duration, Instant};
@@ -6,17 +7,15 @@ use async_trait::async_trait;
 use cdk_common::{nut19, MeltQuoteBolt12Request, MintQuoteBolt12Request, MintQuoteBolt12Response};
 #[cfg(feature = "auth")]
 use cdk_common::{Method, ProtectedEndpoint, RoutePath};
-use reqwest::{Client, IntoUrl};
 use serde::de::DeserializeOwned;
 use serde::Serialize;
 #[cfg(feature = "auth")]
 use tokio::sync::RwLock;
 use tracing::instrument;
-#[cfg(not(target_arch = "wasm32"))]
 use url::Url;
 
+use super::transport::Transport;
 use super::{Error, MintConnector};
-use crate::error::ErrorResponse;
 use crate::mint_url::MintUrl;
 #[cfg(feature = "auth")]
 use crate::nuts::nut22::MintAuthRequest;
@@ -29,119 +28,30 @@ use crate::nuts::{
 #[cfg(feature = "auth")]
 use crate::wallet::auth::{AuthMintConnector, AuthWallet};
 
-#[derive(Debug, Clone)]
-struct HttpClientCore {
-    inner: Client,
-}
-
-impl HttpClientCore {
-    fn new() -> Self {
-        #[cfg(not(target_arch = "wasm32"))]
-        if rustls::crypto::CryptoProvider::get_default().is_none() {
-            let _ = rustls::crypto::ring::default_provider().install_default();
-        }
-
-        Self {
-            inner: Client::new(),
-        }
-    }
-
-    fn client(&self) -> &Client {
-        &self.inner
-    }
-
-    async fn http_get<U: IntoUrl + Send, R: DeserializeOwned>(
-        &self,
-        url: U,
-        auth: Option<AuthToken>,
-    ) -> Result<R, Error> {
-        let mut request = self.client().get(url);
-
-        if let Some(auth) = auth {
-            request = request.header(auth.header_key(), auth.to_string());
-        }
-
-        let response = request
-            .send()
-            .await
-            .map_err(|e| {
-                Error::HttpError(
-                    e.status().map(|status_code| status_code.as_u16()),
-                    e.to_string(),
-                )
-            })?
-            .text()
-            .await
-            .map_err(|e| {
-                Error::HttpError(
-                    e.status().map(|status_code| status_code.as_u16()),
-                    e.to_string(),
-                )
-            })?;
-
-        serde_json::from_str::<R>(&response).map_err(|err| {
-            tracing::warn!("Http Response error: {}", err);
-            match ErrorResponse::from_json(&response) {
-                Ok(ok) => <ErrorResponse as Into<Error>>::into(ok),
-                Err(err) => err.into(),
-            }
-        })
-    }
-
-    async fn http_post<U: IntoUrl + Send, P: Serialize + ?Sized, R: DeserializeOwned>(
-        &self,
-        url: U,
-        auth_token: Option<AuthToken>,
-        payload: &P,
-    ) -> Result<R, Error> {
-        let mut request = self.client().post(url).json(&payload);
-
-        if let Some(auth) = auth_token {
-            request = request.header(auth.header_key(), auth.to_string());
-        }
-
-        let response = request.send().await.map_err(|e| {
-            Error::HttpError(
-                e.status().map(|status_code| status_code.as_u16()),
-                e.to_string(),
-            )
-        })?;
-
-        let response = response.text().await.map_err(|e| {
-            Error::HttpError(
-                e.status().map(|status_code| status_code.as_u16()),
-                e.to_string(),
-            )
-        })?;
-
-        serde_json::from_str::<R>(&response).map_err(|err| {
-            tracing::warn!("Http Response error: {}", err);
-            match ErrorResponse::from_json(&response) {
-                Ok(ok) => <ErrorResponse as Into<Error>>::into(ok),
-                Err(err) => err.into(),
-            }
-        })
-    }
-}
-
 type Cache = (u64, HashSet<(nut19::Method, nut19::Path)>);
 
 /// Http Client
 #[derive(Debug, Clone)]
-pub struct HttpClient {
-    core: HttpClientCore,
+pub struct HttpClient<T>
+where
+    T: Transport + Send + Sync + 'static,
+{
+    transport: Arc<T>,
     mint_url: MintUrl,
     cache_support: Arc<StdRwLock<Cache>>,
     #[cfg(feature = "auth")]
     auth_wallet: Arc<RwLock<Option<AuthWallet>>>,
 }
 
-impl HttpClient {
+impl<T> HttpClient<T>
+where
+    T: Transport + Send + Sync + 'static,
+{
     /// Create new [`HttpClient`]
     #[cfg(feature = "auth")]
     pub fn new(mint_url: MintUrl, auth_wallet: Option<AuthWallet>) -> Self {
         Self {
-            core: HttpClientCore::new(),
+            transport: T::default().into(),
             mint_url,
             auth_wallet: Arc::new(RwLock::new(auth_wallet)),
             cache_support: Default::default(),
@@ -152,7 +62,7 @@ impl HttpClient {
     /// Create new [`HttpClient`]
     pub fn new(mint_url: MintUrl) -> Self {
         Self {
-            core: HttpClientCore::new(),
+            transport: T::default().into(),
             cache_support: Default::default(),
             mint_url,
         }
@@ -176,7 +86,6 @@ impl HttpClient {
         }
     }
 
-    #[cfg(not(target_arch = "wasm32"))]
     /// Create new [`HttpClient`] with a proxy for specific TLDs.
     /// Specifying `None` for `host_matcher` will use the proxy for all
     /// requests.
@@ -186,32 +95,11 @@ impl HttpClient {
         host_matcher: Option<&str>,
         accept_invalid_certs: bool,
     ) -> Result<Self, Error> {
-        let regex = host_matcher
-            .map(regex::Regex::new)
-            .transpose()
-            .map_err(|e| Error::Custom(e.to_string()))?;
-        let client = reqwest::Client::builder()
-            .proxy(reqwest::Proxy::custom(move |url| {
-                if let Some(matcher) = regex.as_ref() {
-                    if let Some(host) = url.host_str() {
-                        if matcher.is_match(host) {
-                            return Some(proxy.clone());
-                        }
-                    }
-                }
-                None
-            }))
-            .danger_accept_invalid_certs(accept_invalid_certs) // Allow self-signed certs
-            .build()
-            .map_err(|e| {
-                Error::HttpError(
-                    e.status().map(|status_code| status_code.as_u16()),
-                    e.to_string(),
-                )
-            })?;
+        let mut transport = T::default();
+        transport.with_proxy(proxy, host_matcher, accept_invalid_certs)?;
 
         Ok(Self {
-            core: HttpClientCore { inner: client },
+            transport: transport.into(),
             mint_url,
             #[cfg(feature = "auth")]
             auth_wallet: Arc::new(RwLock::new(None)),
@@ -231,7 +119,7 @@ impl HttpClient {
         payload: &P,
     ) -> Result<R, Error>
     where
-        P: Serialize + ?Sized,
+        P: Serialize + ?Sized + Send + Sync,
         R: DeserializeOwned,
     {
         let started = Instant::now();
@@ -259,8 +147,12 @@ impl HttpClient {
             })?;
 
             let result = match method {
-                nut19::Method::Get => self.core.http_get(url, auth_token.clone()).await,
-                nut19::Method::Post => self.core.http_post(url, auth_token.clone(), payload).await,
+                nut19::Method::Get => self.transport.http_get(url, auth_token.clone()).await,
+                nut19::Method::Post => {
+                    self.transport
+                        .http_post(url, auth_token.clone(), payload)
+                        .await
+                }
             };
 
             if result.is_ok() {
@@ -291,15 +183,18 @@ impl HttpClient {
 
 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
-impl MintConnector for HttpClient {
+impl<T> MintConnector for HttpClient<T>
+where
+    T: Transport + Send + Sync + 'static,
+{
     /// Get Active Mint Keys [NUT-01]
     #[instrument(skip(self), fields(mint_url = %self.mint_url))]
     async fn get_mint_keys(&self) -> Result<Vec<KeySet>, Error> {
         let url = self.mint_url.join_paths(&["v1", "keys"])?;
 
         Ok(self
-            .core
-            .http_get::<_, KeysResponse>(url, None)
+            .transport
+            .http_get::<KeysResponse>(url, None)
             .await?
             .keysets)
     }
@@ -311,7 +206,7 @@ impl MintConnector for HttpClient {
             .mint_url
             .join_paths(&["v1", "keys", &keyset_id.to_string()])?;
 
-        let keys_response = self.core.http_get::<_, KeysResponse>(url, None).await?;
+        let keys_response = self.transport.http_get::<KeysResponse>(url, None).await?;
 
         Ok(keys_response.keysets.first().unwrap().clone())
     }
@@ -320,7 +215,7 @@ impl MintConnector for HttpClient {
     #[instrument(skip(self), fields(mint_url = %self.mint_url))]
     async fn get_mint_keysets(&self) -> Result<KeysetResponse, Error> {
         let url = self.mint_url.join_paths(&["v1", "keysets"])?;
-        self.core.http_get(url, None).await
+        self.transport.http_get(url, None).await
     }
 
     /// Mint Quote [NUT-04]
@@ -341,7 +236,7 @@ impl MintConnector for HttpClient {
         #[cfg(not(feature = "auth"))]
         let auth_token = None;
 
-        self.core.http_post(url, auth_token, &request).await
+        self.transport.http_post(url, auth_token, &request).await
     }
 
     /// Mint Quote status
@@ -361,7 +256,7 @@ impl MintConnector for HttpClient {
 
         #[cfg(not(feature = "auth"))]
         let auth_token = None;
-        self.core.http_get(url, auth_token).await
+        self.transport.http_get(url, auth_token).await
     }
 
     /// Mint Tokens [NUT-04]
@@ -399,7 +294,7 @@ impl MintConnector for HttpClient {
 
         #[cfg(not(feature = "auth"))]
         let auth_token = None;
-        self.core.http_post(url, auth_token, &request).await
+        self.transport.http_post(url, auth_token, &request).await
     }
 
     /// Melt Quote Status
@@ -419,7 +314,7 @@ impl MintConnector for HttpClient {
 
         #[cfg(not(feature = "auth"))]
         let auth_token = None;
-        self.core.http_get(url, auth_token).await
+        self.transport.http_get(url, auth_token).await
     }
 
     /// Melt [NUT-05]
@@ -467,7 +362,7 @@ impl MintConnector for HttpClient {
     /// Helper to get mint info
     async fn get_mint_info(&self) -> Result<MintInfo, Error> {
         let url = self.mint_url.join_paths(&["v1", "info"])?;
-        let info: MintInfo = self.core.http_get(url, None).await?;
+        let info: MintInfo = self.transport.http_get(url, None).await?;
 
         if let Ok(mut cache_support) = self.cache_support.write() {
             *cache_support = (
@@ -509,7 +404,7 @@ impl MintConnector for HttpClient {
 
         #[cfg(not(feature = "auth"))]
         let auth_token = None;
-        self.core.http_post(url, auth_token, &request).await
+        self.transport.http_post(url, auth_token, &request).await
     }
 
     /// Restore request [NUT-13]
@@ -523,7 +418,7 @@ impl MintConnector for HttpClient {
 
         #[cfg(not(feature = "auth"))]
         let auth_token = None;
-        self.core.http_post(url, auth_token, &request).await
+        self.transport.http_post(url, auth_token, &request).await
     }
 
     /// Mint Quote Bolt12 [NUT-23]
@@ -544,7 +439,7 @@ impl MintConnector for HttpClient {
         #[cfg(not(feature = "auth"))]
         let auth_token = None;
 
-        self.core.http_post(url, auth_token, &request).await
+        self.transport.http_post(url, auth_token, &request).await
     }
 
     /// Mint Quote Bolt12 status
@@ -564,7 +459,7 @@ impl MintConnector for HttpClient {
 
         #[cfg(not(feature = "auth"))]
         let auth_token = None;
-        self.core.http_get(url, auth_token).await
+        self.transport.http_get(url, auth_token).await
     }
 
     /// Melt Quote Bolt12 [NUT-23]
@@ -583,7 +478,7 @@ impl MintConnector for HttpClient {
 
         #[cfg(not(feature = "auth"))]
         let auth_token = None;
-        self.core.http_post(url, auth_token, &request).await
+        self.transport.http_post(url, auth_token, &request).await
     }
 
     /// Melt Quote Bolt12 Status [NUT-23]
@@ -603,7 +498,7 @@ impl MintConnector for HttpClient {
 
         #[cfg(not(feature = "auth"))]
         let auth_token = None;
-        self.core.http_get(url, auth_token).await
+        self.transport.http_get(url, auth_token).await
     }
 
     /// Melt Bolt12 [NUT-23]
@@ -632,18 +527,24 @@ impl MintConnector for HttpClient {
 /// Http Client
 #[derive(Debug, Clone)]
 #[cfg(feature = "auth")]
-pub struct AuthHttpClient {
-    core: HttpClientCore,
+pub struct AuthHttpClient<T>
+where
+    T: Transport + Send + Sync + 'static,
+{
+    transport: Arc<T>,
     mint_url: MintUrl,
     cat: Arc<RwLock<AuthToken>>,
 }
 
 #[cfg(feature = "auth")]
-impl AuthHttpClient {
+impl<T> AuthHttpClient<T>
+where
+    T: Transport + Send + Sync + 'static,
+{
     /// Create new [`AuthHttpClient`]
     pub fn new(mint_url: MintUrl, cat: Option<AuthToken>) -> Self {
         Self {
-            core: HttpClientCore::new(),
+            transport: T::default().into(),
             mint_url,
             cat: Arc::new(RwLock::new(
                 cat.unwrap_or(AuthToken::ClearAuth("".to_string())),
@@ -655,7 +556,10 @@ impl AuthHttpClient {
 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
 #[cfg(feature = "auth")]
-impl AuthMintConnector for AuthHttpClient {
+impl<T> AuthMintConnector for AuthHttpClient<T>
+where
+    T: Transport + Send + Sync + 'static,
+{
     async fn get_auth_token(&self) -> Result<AuthToken, Error> {
         Ok(self.cat.read().await.clone())
     }
@@ -668,7 +572,7 @@ impl AuthMintConnector for AuthHttpClient {
     /// Get Mint Info [NUT-06]
     async fn get_mint_info(&self) -> Result<MintInfo, Error> {
         let url = self.mint_url.join_paths(&["v1", "info"])?;
-        let mint_info: MintInfo = self.core.http_get::<_, MintInfo>(url, None).await?;
+        let mint_info: MintInfo = self.transport.http_get::<MintInfo>(url, None).await?;
 
         Ok(mint_info)
     }
@@ -680,7 +584,7 @@ impl AuthMintConnector for AuthHttpClient {
             self.mint_url
                 .join_paths(&["v1", "auth", "blind", "keys", &keyset_id.to_string()])?;
 
-        let mut keys_response = self.core.http_get::<_, KeysResponse>(url, None).await?;
+        let mut keys_response = self.transport.http_get::<KeysResponse>(url, None).await?;
 
         let keyset = keys_response
             .keysets
@@ -698,14 +602,14 @@ impl AuthMintConnector for AuthHttpClient {
             .mint_url
             .join_paths(&["v1", "auth", "blind", "keysets"])?;
 
-        self.core.http_get(url, None).await
+        self.transport.http_get(url, None).await
     }
 
     /// Mint Tokens [NUT-22]
     #[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
     async fn post_mint_blind_auth(&self, request: MintAuthRequest) -> Result<MintResponse, Error> {
         let url = self.mint_url.join_paths(&["v1", "auth", "blind", "mint"])?;
-        self.core
+        self.transport
             .http_post(url, Some(self.cat.read().await.clone()), &request)
             .await
     }

+ 6 - 3
crates/cdk/src/wallet/mint_connector/mod.rs

@@ -15,11 +15,14 @@ use crate::nuts::{
 #[cfg(feature = "auth")]
 use crate::wallet::AuthWallet;
 
-mod http_client;
+pub mod http_client;
+pub mod transport;
 
+/// Auth HTTP Client with async transport
 #[cfg(feature = "auth")]
-pub use http_client::AuthHttpClient;
-pub use http_client::HttpClient;
+pub type AuthHttpClient = http_client::AuthHttpClient<transport::Async>;
+/// Http Client with async transport
+pub type HttpClient = http_client::HttpClient<transport::Async>;
 
 /// Interface that connects a wallet to a mint. Typically represents an [HttpClient].
 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]

+ 182 - 0
crates/cdk/src/wallet/mint_connector/transport.rs

@@ -0,0 +1,182 @@
+//! HTTP Transport trait with a default implementation
+use std::fmt::Debug;
+
+use cdk_common::AuthToken;
+use reqwest::Client;
+use serde::de::DeserializeOwned;
+use serde::Serialize;
+use url::Url;
+
+use super::Error;
+use crate::error::ErrorResponse;
+
+/// Expected HTTP Transport
+#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
+#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
+pub trait Transport: Default + Send + Sync + Debug + Clone {
+    /// Make the transport to use a given proxy
+    fn with_proxy(
+        &mut self,
+        proxy: Url,
+        host_matcher: Option<&str>,
+        accept_invalid_certs: bool,
+    ) -> Result<(), Error>;
+
+    /// HTTP Get request
+    async fn http_get<R>(&self, url: Url, auth: Option<AuthToken>) -> Result<R, Error>
+    where
+        R: DeserializeOwned;
+
+    /// HTTP Post request
+    async fn http_post<P, R>(
+        &self,
+        url: Url,
+        auth_token: Option<AuthToken>,
+        payload: &P,
+    ) -> Result<R, Error>
+    where
+        P: Serialize + ?Sized + Send + Sync,
+        R: DeserializeOwned;
+}
+
+/// Async transport for Http
+#[derive(Debug, Clone)]
+pub struct Async {
+    inner: Client,
+}
+
+impl Default for Async {
+    fn default() -> Self {
+        #[cfg(not(target_arch = "wasm32"))]
+        if rustls::crypto::CryptoProvider::get_default().is_none() {
+            let _ = rustls::crypto::ring::default_provider().install_default();
+        }
+
+        Self {
+            inner: Client::new(),
+        }
+    }
+}
+
+#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
+#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
+impl Transport for Async {
+    #[cfg(target_arch = "wasm32")]
+    fn with_proxy(
+        &mut self,
+        _proxy: Url,
+        _host_matcher: Option<&str>,
+        _accept_invalid_certs: bool,
+    ) -> Result<(), Error> {
+        panic!("Not supported in wasm");
+    }
+
+    #[cfg(not(target_arch = "wasm32"))]
+    fn with_proxy(
+        &mut self,
+        proxy: Url,
+        host_matcher: Option<&str>,
+        accept_invalid_certs: bool,
+    ) -> Result<(), Error> {
+        let regex = host_matcher
+            .map(regex::Regex::new)
+            .transpose()
+            .map_err(|e| Error::Custom(e.to_string()))?;
+        self.inner = reqwest::Client::builder()
+            .proxy(reqwest::Proxy::custom(move |url| {
+                if let Some(matcher) = regex.as_ref() {
+                    if let Some(host) = url.host_str() {
+                        if matcher.is_match(host) {
+                            return Some(proxy.clone());
+                        }
+                    }
+                }
+                None
+            }))
+            .danger_accept_invalid_certs(accept_invalid_certs) // Allow self-signed certs
+            .build()
+            .map_err(|e| {
+                Error::HttpError(
+                    e.status().map(|status_code| status_code.as_u16()),
+                    e.to_string(),
+                )
+            })?;
+        Ok(())
+    }
+
+    async fn http_get<R>(&self, url: Url, auth: Option<AuthToken>) -> Result<R, Error>
+    where
+        R: DeserializeOwned,
+    {
+        let mut request = self.inner.get(url);
+
+        if let Some(auth) = auth {
+            request = request.header(auth.header_key(), auth.to_string());
+        }
+
+        let response = request
+            .send()
+            .await
+            .map_err(|e| {
+                Error::HttpError(
+                    e.status().map(|status_code| status_code.as_u16()),
+                    e.to_string(),
+                )
+            })?
+            .text()
+            .await
+            .map_err(|e| {
+                Error::HttpError(
+                    e.status().map(|status_code| status_code.as_u16()),
+                    e.to_string(),
+                )
+            })?;
+
+        serde_json::from_str::<R>(&response).map_err(|err| {
+            tracing::warn!("Http Response error: {}", err);
+            match ErrorResponse::from_json(&response) {
+                Ok(ok) => <ErrorResponse as Into<Error>>::into(ok),
+                Err(err) => err.into(),
+            }
+        })
+    }
+
+    async fn http_post<P, R>(
+        &self,
+        url: Url,
+        auth_token: Option<AuthToken>,
+        payload: &P,
+    ) -> Result<R, Error>
+    where
+        P: Serialize + ?Sized + Send + Sync,
+        R: DeserializeOwned,
+    {
+        let mut request = self.inner.post(url).json(&payload);
+
+        if let Some(auth) = auth_token {
+            request = request.header(auth.header_key(), auth.to_string());
+        }
+
+        let response = request.send().await.map_err(|e| {
+            Error::HttpError(
+                e.status().map(|status_code| status_code.as_u16()),
+                e.to_string(),
+            )
+        })?;
+
+        let response = response.text().await.map_err(|e| {
+            Error::HttpError(
+                e.status().map(|status_code| status_code.as_u16()),
+                e.to_string(),
+            )
+        })?;
+
+        serde_json::from_str::<R>(&response).map_err(|err| {
+            tracing::warn!("Http Response error: {}", err);
+            match ErrorResponse::from_json(&response) {
+                Ok(ok) => <ErrorResponse as Into<Error>>::into(ok),
+                Err(err) => err.into(),
+            }
+        })
+    }
+}

+ 4 - 0
crates/cdk/src/wallet/mod.rs

@@ -54,6 +54,10 @@ pub use auth::{AuthMintConnector, AuthWallet};
 pub use builder::WalletBuilder;
 pub use cdk_common::wallet as types;
 #[cfg(feature = "auth")]
+pub use mint_connector::http_client::AuthHttpClient as BaseAuthHttpClient;
+pub use mint_connector::http_client::HttpClient as BaseHttpClient;
+pub use mint_connector::transport::Transport as HttpTransport;
+#[cfg(feature = "auth")]
 pub use mint_connector::AuthHttpClient;
 pub use mint_connector::{HttpClient, MintConnector};
 pub use multi_mint_wallet::MultiMintWallet;

+ 21 - 1
crates/cdk/src/wallet/subscription/http.rs

@@ -2,6 +2,7 @@ use std::collections::HashMap;
 use std::sync::Arc;
 use std::time::Duration;
 
+use cdk_common::MintQuoteBolt12Response;
 use tokio::sync::{mpsc, RwLock};
 use tokio::time;
 
@@ -15,6 +16,7 @@ use crate::Wallet;
 #[derive(Debug, Hash, PartialEq, Eq)]
 enum UrlType {
     Mint(String),
+    MintBolt12(String),
     Melt(String),
     PublicKey(nut01::PublicKey),
 }
@@ -22,6 +24,7 @@ enum UrlType {
 #[derive(Debug, Eq, PartialEq)]
 enum AnyState {
     MintQuoteState(nut23::QuoteState),
+    MintBolt12QuoteState(MintQuoteBolt12Response<String>),
     MeltQuoteState(nut05::QuoteState),
     PublicKey(nut07::State),
     Empty,
@@ -67,7 +70,12 @@ async fn convert_subscription(
             }
         }
         Kind::Bolt12MintQuote => {
-            for id in sub.1.filters.iter().map(|id| UrlType::Mint(id.clone())) {
+            for id in sub
+                .1
+                .filters
+                .iter()
+                .map(|id| UrlType::MintBolt12(id.clone()))
+            {
                 subscribed_to.insert(id, (sub.0.clone(), sub.1.id.clone(), AnyState::Empty));
             }
         }
@@ -98,6 +106,18 @@ pub async fn http_main<S: IntoIterator<Item = SubId>>(
                 for (url, (sender, _, last_state)) in subscribed_to.iter_mut() {
                     tracing::debug!("Polling: {:?}", url);
                     match url {
+                        UrlType::MintBolt12(id) => {
+                            let response = http_client.get_mint_quote_bolt12_status(id).await;
+                            if let Ok(response) = response {
+                                if *last_state == AnyState::MintBolt12QuoteState(response.clone()) {
+                                    continue;
+                                }
+                                *last_state = AnyState::MintBolt12QuoteState(response.clone());
+                                if let Err(err) = sender.try_send(NotificationPayload::MintQuoteBolt12Response(response)) {
+                                    tracing::error!("Error sending mint quote response: {:?}", err);
+                                }
+                            }
+                        },
                         UrlType::Mint(id) => {
 
                             let response = http_client.get_mint_quote_status(id).await;

+ 9 - 1
crates/cdk/src/wallet/subscription/mod.rs

@@ -48,14 +48,16 @@ type WsSubscriptionBody = (mpsc::Sender<NotificationPayload>, Params);
 pub struct SubscriptionManager {
     all_connections: Arc<RwLock<HashMap<MintUrl, SubscriptionClient>>>,
     http_client: Arc<dyn MintConnector + Send + Sync>,
+    prefer_http: bool,
 }
 
 impl SubscriptionManager {
     /// Create a new subscription manager
-    pub fn new(http_client: Arc<dyn MintConnector + Send + Sync>) -> Self {
+    pub fn new(http_client: Arc<dyn MintConnector + Send + Sync>, prefer_http: bool) -> Self {
         Self {
             all_connections: Arc::new(RwLock::new(HashMap::new())),
             http_client,
+            prefer_http,
         }
     }
 
@@ -93,6 +95,12 @@ impl SubscriptionManager {
             ))]
             let is_ws_support = false;
 
+            let is_ws_support = if self.prefer_http {
+                false
+            } else {
+                is_ws_support
+            };
+
             tracing::debug!(
                 "Connect to {:?} to subscribe. WebSocket is supported ({})",
                 mint_url,

+ 10 - 26
crates/cdk/src/wallet/subscription/ws.rs

@@ -18,25 +18,6 @@ use crate::Wallet;
 
 const MAX_ATTEMPT_FALLBACK_HTTP: usize = 10;
 
-async fn fallback_to_http<S: IntoIterator<Item = SubId>>(
-    initial_state: S,
-    http_client: Arc<dyn MintConnector + Send + Sync>,
-    subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
-    new_subscription_recv: mpsc::Receiver<SubId>,
-    on_drop: mpsc::Receiver<SubId>,
-    wallet: Arc<Wallet>,
-) {
-    http_main(
-        initial_state,
-        http_client,
-        subscriptions,
-        new_subscription_recv,
-        on_drop,
-        wallet,
-    )
-    .await
-}
-
 #[inline]
 pub async fn ws_main(
     http_client: Arc<dyn MintConnector + Send + Sync>,
@@ -72,7 +53,8 @@ pub async fn ws_main(
                     tracing::error!(
                         "Could not connect to server after {MAX_ATTEMPT_FALLBACK_HTTP} attempts, falling back to HTTP-subscription client"
                     );
-                    return fallback_to_http(
+
+                    return http_main(
                         active_subscriptions.into_keys(),
                         http_client,
                         subscriptions,
@@ -169,17 +151,19 @@ pub async fn ws_main(
                         WsMessageOrResponse::ErrorResponse(error) => {
                             tracing::error!("Received error from server: {:?}", error);
                             if subscription_requests.contains(&error.id) {
-                                // If the server sends an error response to a subscription request, we should
-                                // fallback to HTTP.
-                                // TODO: Add some retry before giving up to HTTP.
-                                return fallback_to_http(
+                                tracing::error!(
+                                    "Falling back to HTTP client"
+                                );
+
+                                return http_main(
                                     active_subscriptions.into_keys(),
                                     http_client,
                                     subscriptions,
                                     new_subscription_recv,
                                     on_drop,
-                                    wallet
-                                ).await;
+                                    wallet,
+                                )
+                                .await;
                             }
                         }
                     }

+ 9 - 9
flake.lock

@@ -23,11 +23,11 @@
         "rust-analyzer-src": []
       },
       "locked": {
-        "lastModified": 1755585599,
-        "narHash": "sha256-tl/0cnsqB/Yt7DbaGMel2RLa7QG5elA8lkaOXli6VdY=",
+        "lastModified": 1756622179,
+        "narHash": "sha256-K3CimrAcMhdDYkErd3oiWPZNaoyaGZEuvGrFuDPFMZY=",
         "owner": "nix-community",
         "repo": "fenix",
-        "rev": "6ed03ef4c8ec36d193c18e06b9ecddde78fb7e42",
+        "rev": "0abcb15ae6279dcb40a8ae7c1ed980705245cb79",
         "type": "github"
       },
       "original": {
@@ -93,11 +93,11 @@
     },
     "nixpkgs": {
       "locked": {
-        "lastModified": 1755922037,
-        "narHash": "sha256-wY1+2JPH0ZZC4BQefoZw/k+3+DowFyfOxv17CN/idKs=",
+        "lastModified": 1756469547,
+        "narHash": "sha256-YvtD2E7MYsQ3r7K9K2G7nCslCKMPShoSEAtbjHLtH0k=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "b1b3291469652d5a2edb0becc4ef0246fff97a7c",
+        "rev": "41d292bfc37309790f70f4c120b79280ce40af16",
         "type": "github"
       },
       "original": {
@@ -160,11 +160,11 @@
         ]
       },
       "locked": {
-        "lastModified": 1756089517,
-        "narHash": "sha256-KGinVKturJFPrRebgvyUB1BUNqf1y9FN+tSJaTPlnFE=",
+        "lastModified": 1756607787,
+        "narHash": "sha256-ciwAdgtlAN1PCaidWK6RuWsTBL8DVuyDCGM+X3ein5Q=",
         "owner": "oxalica",
         "repo": "rust-overlay",
-        "rev": "44774c8c83cd392c50914f86e1ff75ef8619f1cd",
+        "rev": "f46d294b87ebb9f7124f1ce13aa2a5f5acc0f3eb",
         "type": "github"
       },
       "original": {

+ 5 - 1
flake.nix

@@ -68,6 +68,11 @@
         # MSRV stable
         msrv_toolchain = pkgs.rust-bin.stable."1.85.0".default.override {
           targets = [ "wasm32-unknown-unknown" ]; # wasm
+          extensions = [
+            "rustfmt"
+            "clippy"
+            "rust-analyzer"
+          ];
         };
 
         # Nightly used for formatting
@@ -114,7 +119,6 @@
 
         # Common arguments can be set here to avoid repeating them later
         nativeBuildInputs = [
-          pkgs.rust-analyzer
           #Add additional build inputs here
         ]
         ++ lib.optionals isDarwin [

+ 2 - 0
justfile

@@ -325,6 +325,7 @@ release m="":
     "-p cdk-common"
     "-p cdk-sql-common"
     "-p cdk-sqlite"
+    "-p cdk-postgres"
     "-p cdk-redb"
     "-p cdk-signatory"
     "-p cdk"
@@ -333,6 +334,7 @@ release m="":
     "-p cdk-cln"
     "-p cdk-lnd"
     "-p cdk-lnbits"
+    "-p cdk-ldk-node"
     "-p cdk-fake-wallet"
     "-p cdk-payment-processor"
     "-p cdk-cli"

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio