diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ce7a94..738b33c5 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/README.md b/README.md index 191679cc..d1db92c7 100644 --- a/README.md +++ b/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 diff --git a/crates/cashu/src/lib.rs b/crates/cashu/src/lib.rs index 6604775a..4b1877c7 100644 --- a/crates/cashu/src/lib.rs +++ b/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 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 { - // 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(deserializer: D) -> Result - 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 - ))) - } - } -} diff --git a/crates/cashu/src/nuts/mod.rs b/crates/cashu/src/nuts/mod.rs index 8e8331f9..658887f5 100644 --- a/crates/cashu/src/nuts/mod.rs +++ b/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}; diff --git a/crates/cashu/src/nuts/nut13.rs b/crates/cashu/src/nuts/nut13.rs index 054da85b..66fcf808 100644 --- a/crates/cashu/src/nuts/nut13.rs +++ b/crates/cashu/src/nuts/nut13.rs @@ -3,7 +3,7 @@ //! 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 { 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::::new(seed); + let mut engine = HmacEngine::::new(seed); engine.input(&message); - let hmac_result = hmac::Hmac::::from_engine(engine); + let hmac_result = hmac::Hmac::::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 { 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::::new(seed); + let mut engine = HmacEngine::::new(seed); engine.input(&message); - let hmac_result = hmac::Hmac::::from_engine(engine); + let hmac_result = hmac::Hmac::::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); } } diff --git a/crates/cashu/src/nuts/nut18/payment_request.rs b/crates/cashu/src/nuts/nut18/payment_request.rs index 73597012..0d828726 100644 --- a/crates/cashu/src/nuts/nut18/payment_request.rs +++ b/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 diff --git a/crates/cashu/src/nuts/nut24.rs b/crates/cashu/src/nuts/nut25.rs similarity index 100% rename from crates/cashu/src/nuts/nut24.rs rename to crates/cashu/src/nuts/nut25.rs diff --git a/crates/cashu/src/quote_id.rs b/crates/cashu/src/quote_id.rs new file mode 100644 index 00000000..58ccac59 --- /dev/null +++ b/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 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 { + // 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(deserializer: D) -> Result + 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 + ))) + } +} diff --git a/crates/cdk-cln/src/lib.rs b/crates/cdk-cln/src/lib.rs index 66e142bd..0bd797d4 100644 --- a/crates/cdk-cln/src/lib.rs +++ b/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 + Send>>, Self::Err> { + ) -> Result + 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}"); diff --git a/crates/cdk-common/src/database/mint/mod.rs b/crates/cdk-common/src/database/mint/mod.rs index 3f3c1d3a..a48d29c3 100644 --- a/crates/cdk-common/src/database/mint/mod.rs +++ b/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, 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, ) -> 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) -> Result<(), Self::Err>; + async fn add_proofs( + &mut self, + proof: Proofs, + quote_id: Option, + ) -> 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, + quote_id: Option, ) -> Result<(), Self::Err>; } @@ -190,7 +193,10 @@ pub trait ProofsDatabase { /// Get [`Proofs`] by ys async fn get_proofs_by_ys(&self, ys: &[PublicKey]) -> Result>, Self::Err>; /// Get ys by quote id - async fn get_proof_ys_by_quote_id(&self, quote_id: &Uuid) -> Result, Self::Err>; + async fn get_proof_ys_by_quote_id( + &self, + quote_id: &QuoteId, + ) -> Result, Self::Err>; /// Get [`Proofs`] state async fn get_proofs_states(&self, ys: &[PublicKey]) -> Result>, Self::Err>; /// Get [`Proofs`] by state diff --git a/crates/cdk-common/src/database/mint/test.rs b/crates/cdk-common/src/database/mint/test.rs index d8b69d08..e7455fd2 100644 --- a/crates/cdk-common/src/database/mint/test.rs +++ b/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; diff --git a/crates/cdk-common/src/payment.rs b/crates/cdk-common/src/payment.rs index afd07ee2..1bcabfcd 100644 --- a/crates/cdk-common/src/payment.rs +++ b/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 + Send>>, Self::Err>; + ) -> Result + Send>>, Self::Err>; /// Is wait invoice active fn is_wait_invoice_active(&self) -> bool; @@ -318,6 +318,13 @@ pub trait MintPayment { ) -> Result; } +/// 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 { diff --git a/crates/cdk-common/src/wallet.rs b/crates/cdk-common/src/wallet.rs index 7359d730..f4a55429 100644 --- a/crates/cdk-common/src/wallet.rs +++ b/crates/cdk-common/src/wallet.rs @@ -84,6 +84,9 @@ pub struct MeltQuote { pub expiry: u64, /// Payment preimage pub payment_preimage: Option, + /// Payment method + #[serde(default)] + pub payment_method: PaymentMethod, } impl MintQuote { diff --git a/crates/cdk-fake-wallet/src/lib.rs b/crates/cdk-fake-wallet/src/lib.rs index 13a5fee9..a5b0f344 100644 --- a/crates/cdk-fake-wallet/src/lib.rs +++ b/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 + Send>>, Self::Err> { + ) -> Result + 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) }, ))) } diff --git a/crates/cdk-integration-tests/tests/bolt12.rs b/crates/cdk-integration-tests/tests/bolt12.rs index 44873f74..45c7458d 100644 --- a/crates/cdk-integration-tests/tests/bolt12.rs +++ b/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?; diff --git a/crates/cdk-integration-tests/tests/fake_auth.rs b/crates/cdk-integration-tests/tests/fake_auth.rs index ddf700a6..75c306c0 100644 --- a/crates/cdk-integration-tests/tests/fake_auth.rs +++ b/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"); diff --git a/crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs b/crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs index 0db6cf25..ba2a06bd 100644 --- a/crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs +++ b/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"); diff --git a/crates/cdk-integration-tests/tests/ldk_node.rs b/crates/cdk-integration-tests/tests/ldk_node.rs index 51f5327c..6f8b8bf4 100644 --- a/crates/cdk-integration-tests/tests/ldk_node.rs +++ b/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("e_request) .send() .await?; diff --git a/crates/cdk-integration-tests/tests/regtest.rs b/crates/cdk-integration-tests/tests/regtest.rs index fb66ce88..e726ab25 100644 --- a/crates/cdk-integration-tests/tests/regtest.rs +++ b/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"); diff --git a/crates/cdk-integration-tests/tests/test_fees.rs b/crates/cdk-integration-tests/tests/test_fees.rs index b6fca782..f63dbe06 100644 --- a/crates/cdk-integration-tests/tests/test_fees.rs +++ b/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"); diff --git a/crates/cdk-ldk-node/Cargo.toml b/crates/cdk-ldk-node/Cargo.toml index 94e3ca0b..e252aaa3 100644 --- a/crates/cdk-ldk-node/Cargo.toml +++ b/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" - - - diff --git a/crates/cdk-ldk-node/src/lib.rs b/crates/cdk-ldk-node/src/lib.rs index dde3b5ed..7f1e7805 100644 --- a/crates/cdk-ldk-node/src/lib.rs +++ b/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 + Send>>, Self::Err> { + ) -> Result + 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 diff --git a/crates/cdk-ldk-node/src/web/handlers/channels.rs b/crates/cdk-ldk-node/src/web/handlers/channels.rs index 238f9365..dcbe010f 100644 --- a/crates/cdk-ldk-node/src/web/handlers/channels.rs +++ b/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, + State(state): State, query: Query>, ) -> Result, 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) } + 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)) } + } + } } - div class="info-item" { - span class="info-label" { "Node ID:" } - span class="info-value" style="font-family: monospace; font-size: 0.85rem;" { (node_id) } - } - 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, + State(state): State, query: Query>, ) -> Result, 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) } + 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)) } + } + } } - div class="info-item" { - span class="info-label" { "Node ID:" } - span class="info-value" style="font-family: monospace; font-size: 0.85rem;" { (node_id) } - } - 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" } } }, ); diff --git a/crates/cdk-ldk-node/src/web/handlers/lightning.rs b/crates/cdk-ldk-node/src/web/handlers/lightning.rs index 344b78b3..8ca3b544 100644 --- a/crates/cdk-ldk-node/src/web/handlers/lightning.rs +++ b/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) -> Result, StatusCode> { @@ -26,18 +26,35 @@ pub async fn balance_page(State(state): State) -> Result, 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) -> Result, 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) -> Result, } } - div class="card" { - h2 { "Channel Details" } + // Channel Details header (outside card) + h2 class="section-header" { "Channel Details" } - // 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()) } + // Channels list + @for (index, channel) in channels.iter().enumerate() { + @let node_id = channel.counterparty_node_id.to_string(); + @let channel_number = index + 1; + + 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" } } } } } - } } }; diff --git a/crates/cdk-ldk-node/src/web/handlers/onchain.rs b/crates/cdk-ldk-node/src/web/handlers/onchain.rs index ee34076f..1658d0e0 100644 --- a/crates/cdk-ldk-node/src/web/handlers/onchain.rs +++ b/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) { diff --git a/crates/cdk-ldk-node/src/web/templates/layout.rs b/crates/cdk-ldk-node/src/web/templates/layout.rs index d055390d..4fd25ef8 100644 --- a/crates/cdk-ldk-node/src/web/templates/layout.rs +++ b/crates/cdk-ldk-node/src/web/templates/layout.rs @@ -34,74 +34,299 @@ pub fn layout(title: &str, content: Markup) -> Markup { --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; --radius: 0.5rem; - + /* Typography scale */ --fs-title: 1.25rem; --fs-label: 0.8125rem; --fs-value: 1.625rem; - + /* Line heights */ --lh-tight: 1.15; --lh-normal: 1.4; - + /* Font weights */ --fw-medium: 500; --fw-semibold: 600; --fw-bold: 700; - + /* Colors */ --fg-primary: #0f172a; --fg-muted: #6b7280; - + /* Header text colors for light mode */ --header-title: #000000; --header-subtitle: #333333; } - /* Dark mode using system preference */ + /* Dark mode using system preference */ @media (prefers-color-scheme: dark) { + body { + background: linear-gradient(rgb(23, 25, 29), rgb(18, 19, 21)); + } + :root { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 84% 4.9%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; + --background: 0 0% 0%; + --foreground: 0 0% 100%; + --card: 0 0% 0%; + --card-foreground: 0 0% 100%; + --popover: 0 0% 0%; + --popover-foreground: 0 0% 100%; + --primary: 0 0% 100%; + --primary-foreground: 0 0% 0%; + --secondary: 0 0% 20%; + --secondary-foreground: 0 0% 100%; + --muted: 0 0% 20%; + --muted-foreground: 0 0% 70%; + --accent: 0 0% 20%; + --accent-foreground: 0 0% 100%; --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; - - /* Dark mode colors */ - --fg-primary: #f8fafc; - --fg-muted: #94a3b8; - + --destructive-foreground: 0 0% 100%; + --border: 0 0% 20%; + --input: 0 0% 20%; + --ring: 0 0% 83.9%; + + /* Dark mode text hierarchy colors */ + --text-primary: #ffffff; + --text-secondary: #e6e6e6; + --text-tertiary: #cccccc; + --text-quaternary: #b3b3b3; + --text-muted: #999999; + --text-muted-2: #888888; + --text-muted-3: #666666; + --text-muted-4: #333333; + --text-subtle: #1a1a1a; + /* Header text colors for dark mode */ --header-title: #ffffff; - --header-subtitle: #e2e8f0; + --header-subtitle: #e6e6e6; + } + + /* Dark mode box styling - no borders, subtle background */ + .card { + background-color: rgba(255, 255, 255, 0.03) !important; + border: none !important; + } + + .channel-box { + background-color: rgba(255, 255, 255, 0.03) !important; + border: none !important; + } + + .metric-card { + background-color: rgba(255, 255, 255, 0.03) !important; + border: none !important; + } + + .balance-item { + background-color: rgba(255, 255, 255, 0.03) !important; + border: none !important; + } + + .node-info-main-container { + background-color: rgba(255, 255, 255, 0.03) !important; + border: none !important; + } + + .node-avatar { + background-color: rgba(255, 255, 255, 0.03) !important; + border: none !important; + } + + /* Text hierarchy colors */ + .section-header { + color: var(--text-primary) !important; + } + + .channel-alias { + color: var(--text-primary) !important; + } + + .detail-label { + color: var(--text-muted) !important; + } + + .detail-value, .detail-value-amount { + color: var(--text-secondary) !important; + } + + .metric-label, .balance-label { + color: var(--text-muted) !important; + } + + .metric-value, .balance-amount { + color: var(--text-primary) !important; + } + + /* Page headers and section titles */ + h1, h2, h3, h4, h5, h6 { + color: var(--text-primary) !important; + } + + /* Form card titles */ + .form-card h2, .form-card h3 { + color: var(--text-primary) !important; + } + + /* Quick action cards styling */ + .quick-action-card { + background-color: rgba(255, 255, 255, 0.03) !important; + border: none !important; + border-radius: 0.75rem !important; + padding: 1.5rem !important; + } + + /* Dark mode outline button styling */ + .button-outline { + background-color: transparent !important; + color: var(--text-primary) !important; + border: 1px solid var(--text-muted) !important; + } + + .button-outline:hover { + background-color: rgba(255, 255, 255, 0.2) !important; + } + + /* Navigation dark mode styling */ + nav { + background-color: transparent !important; + border-top: none !important; + border-bottom: none !important; + } + + } + + /* New Header Layout Styles */ + .header-content { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + } + + .header-left { + display: flex; + align-items: center; + gap: 1rem; + } + + .header-avatar { + flex-shrink: 0; + background-color: hsl(var(--muted) / 0.3); + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + padding: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + width: 80px; + height: 80px; + } + + .header-avatar-image { + width: 48px; + height: 48px; + border-radius: calc(var(--radius) - 2px); + object-fit: cover; + display: block; + } + + .node-info { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding-top: 0; + margin-top: 0; + } + + .node-status { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .status-indicator { + width: 0.75rem; + height: 0.75rem; + border-radius: 50%; + background-color: #10b981; + box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2); + } + + .status-text { + font-size: 0.875rem; + font-weight: 500; + color: #10b981; + } + + .node-title { + font-size: 1.875rem; + font-weight: 600; + color: var(--header-title); + margin: 0; + line-height: 1.1; + } + + .node-subtitle { + font-size: 0.875rem; + color: var(--header-subtitle); + font-weight: 500; + } + + .header-right { + display: flex; + align-items: center; + } + + + + /* Responsive header */ + @media (max-width: 768px) { + .header-content { + flex-direction: column; + gap: 1rem; + text-align: center; + } + + .header-left { + flex-direction: column; + text-align: center; + } + + .node-title { + font-size: 1.5rem; } } - + + nav a { + color: var(--text-muted) !important; + } + + nav a:hover { + color: var(--text-secondary) !important; + background-color: rgba(255, 255, 255, 0.05) !important; + } + + nav a.active { + color: var(--text-primary) !important; + background-color: rgba(255, 255, 255, 0.08) !important; + } + + nav a.active:hover { + background-color: rgba(255, 255, 255, 0.1) !important; + } + } + * { box-sizing: border-box; margin: 0; padding: 0; } - + html { font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11'; font-variation-settings: normal; } - + body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; font-size: 14px; @@ -114,19 +339,19 @@ pub fn layout(title: &str, content: Markup) -> Markup { text-rendering: geometricPrecision; min-height: 100vh; } - + .container { max-width: 1200px; margin: 0 auto; padding: 0 1rem; } - + @media (min-width: 640px) { .container { padding: 0 2rem; } } - + /* Hero section styling */ header { position: relative; @@ -135,34 +360,37 @@ pub fn layout(title: &str, content: Markup) -> Markup { background-position: center; background-repeat: no-repeat; border-bottom: 1px solid hsl(var(--border)); - margin-bottom: 3rem; - text-align: center; + margin-bottom: 2rem; + text-align: left; width: 100%; - height: 400px; /* Fixed height for better proportion */ + height: 200px; /* Reduced height for more compact header */ display: flex; align-items: center; - justify-content: center; + justify-content: flex-start; } - + /* Dark mode header background - using different image */ @media (prefers-color-scheme: dark) { header { background-image: url('/static/images/bg-dark.jpg?v=3'); } } - + /* Ensure text is positioned properly */ header .container { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); + position: relative; + top: auto; + left: auto; + transform: none; z-index: 2; width: 100%; max-width: 1200px; padding: 0 2rem; + display: flex; + align-items: center; + justify-content: flex-start; } - + h1 { font-size: 3rem; font-weight: 700; @@ -171,7 +399,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { color: var(--header-title); margin-bottom: 1rem; } - + .subtitle { font-size: 1.25rem; color: var(--header-subtitle); @@ -180,35 +408,35 @@ pub fn layout(title: &str, content: Markup) -> Markup { margin: 0 auto; line-height: 1.6; } - + @media (max-width: 768px) { header { - height: 300px; /* Smaller height on mobile */ + height: 150px; /* Smaller height on mobile */ } - + header .container { padding: 0 1rem; } - + h1 { font-size: 2.25rem; } - + .subtitle { font-size: 1.1rem; } } - + /* Card fade-in animation */ @keyframes fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } - + .card { animation: fade-in 0.3s ease-out; } - + /* Modern Navigation Bar Styling */ nav { background-color: hsl(var(--card)); @@ -220,13 +448,13 @@ pub fn layout(title: &str, content: Markup) -> Markup { padding: 0.75rem; margin-bottom: 2rem; } - + nav .container { padding: 0; display: flex; justify-content: center; } - + nav ul { list-style: none; display: flex; @@ -237,11 +465,11 @@ pub fn layout(title: &str, content: Markup) -> Markup { padding: 0; justify-content: center; } - + nav li { flex-shrink: 0; } - + nav a { display: inline-flex; align-items: center; @@ -257,22 +485,22 @@ pub fn layout(title: &str, content: Markup) -> Markup { position: relative; min-height: 3rem; } - + nav a:hover { color: hsl(var(--foreground)); background-color: hsl(var(--muted)); } - + nav a.active { color: hsl(var(--primary-foreground)); background-color: hsl(var(--primary)); font-weight: 700; } - + nav a.active:hover { background-color: hsl(var(--primary) / 0.9); } - + .card { background-color: hsl(var(--card)); border: 1px solid hsl(var(--border)); @@ -281,7 +509,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { margin-bottom: 1.5rem; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); } - + /* Metric cards styling - matching balance-item style */ .metrics-container { display: flex; @@ -289,7 +517,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { margin: 1rem 0; flex-wrap: wrap; } - + .metric-card { flex: 1; min-width: 200px; @@ -299,7 +527,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { border-radius: calc(var(--radius) - 2px); border: 1px solid hsl(var(--border)); } - + .metric-value { font-size: 1.5rem; font-weight: 600; @@ -307,13 +535,13 @@ pub fn layout(title: &str, content: Markup) -> Markup { margin-bottom: 0.5rem; line-height: 1.2; } - + .metric-label { font-size: 0.875rem; color: hsl(var(--muted-foreground)); font-weight: 400; } - + .card h2, .section-title, h2 { @@ -324,7 +552,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { text-transform: none; margin: 0 0 12px; } - + h3 { font-size: var(--fs-title); line-height: var(--lh-tight); @@ -333,11 +561,11 @@ pub fn layout(title: &str, content: Markup) -> Markup { text-transform: none; margin: 0 0 12px; } - + .form-group { margin-bottom: 1.5rem; } - + label { display: block; font-size: 0.875rem; @@ -345,7 +573,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { color: hsl(var(--foreground)); margin-bottom: 0.5rem; } - + input, textarea, select { flex: 1; background-color: hsl(var(--background)); @@ -358,19 +586,19 @@ pub fn layout(title: &str, content: Markup) -> Markup { transition: border-color 150ms ease-in-out, box-shadow 150ms ease-in-out; width: 100%; } - + input:focus, textarea:focus, select:focus { outline: 2px solid transparent; outline-offset: 2px; border-color: hsl(var(--ring)); box-shadow: 0 0 0 2px hsl(var(--ring)); } - + input:disabled, textarea:disabled, select:disabled { cursor: not-allowed; opacity: 0.5; } - + button { display: inline-flex; align-items: center; @@ -387,79 +615,82 @@ pub fn layout(title: &str, content: Markup) -> Markup { background-color: hsl(var(--primary)); color: hsl(var(--primary-foreground)); } - + button:hover { background-color: hsl(var(--primary) / 0.9); } - + button:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px; } - + button:disabled { pointer-events: none; opacity: 0.5; } - + .button-secondary { background-color: hsl(var(--secondary)); color: hsl(var(--secondary-foreground)); border: 1px solid hsl(var(--input)); } - + .button-secondary:hover { background-color: hsl(var(--secondary) / 0.8); } - + .button-outline { border: 1px solid hsl(var(--input)); background-color: hsl(var(--background)); color: hsl(var(--foreground)); } - + .button-outline:hover { background-color: hsl(var(--accent)); color: hsl(var(--accent-foreground)); } - + .button-destructive { - background-color: hsl(var(--destructive)); - color: hsl(var(--destructive-foreground)); + background-color: transparent !important; + color: #DC2626 !important; + border: 1px solid #DC2626 !important; } - + .button-destructive:hover { - background-color: hsl(var(--destructive) / 0.9); + background-color: rgba(220, 38, 38, 0.2) !important; } - + + + .button-sm { height: 2rem; border-radius: calc(var(--radius) - 4px); padding: 0 0.75rem; font-size: 0.75rem; } - + .button-lg { height: 2.75rem; border-radius: var(--radius); padding: 0 2rem; font-size: 1rem; } - + .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1.5rem; } - + @media (max-width: 640px) { .grid { grid-template-columns: 1fr; } } - - + + .info-label, .sub-label, label { @@ -471,7 +702,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { letter-spacing: 0.02em; flex-shrink: 0; } - + .info-value { font-size: 0.875rem; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; @@ -482,7 +713,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { hyphens: auto; min-width: 0; } - + .info-item { display: flex; gap: 0.5rem; @@ -493,43 +724,43 @@ pub fn layout(title: &str, content: Markup) -> Markup { min-height: 3rem; justify-content: space-between; } - + .info-item:last-child { border-bottom: none; } - + /* Card flex spacing improvements */ .card-flex { display: flex; gap: 1rem; align-items: center; } - + .card-flex-content { flex: 1 1 auto; } - + .card-flex-button { flex: 0 0 auto; } - + .card-flex-content p { margin: 0 0 12px; line-height: var(--lh-normal); } - + .card-flex-content p + .card-flex-button, .card-flex-content p + a, .card-flex-content p + button { margin-top: 12px; } - + .card-flex-content .body + .card-flex-button, .card-flex-content .body + a, .card-flex-content .body + button { margin-top: 12px; } - + .truncate-value { font-size: 0.875rem; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; @@ -541,7 +772,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { display: inline-block; max-width: 200px; } - + .copy-button { background-color: hsl(var(--secondary)); color: hsl(var(--secondary-foreground)); @@ -557,24 +788,24 @@ pub fn layout(title: &str, content: Markup) -> Markup { min-height: auto; flex-shrink: 0; } - + .copy-button:hover { background-color: hsl(var(--secondary) / 0.8); border-color: hsl(var(--border)); } - + .balance-item, .balance-item-container { padding: 1.25rem 0; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 10px; } - + .balance-item:last-child, .balance-item-container:last-child { border-bottom: none; } - + .balance-item .balance-label, .balance-item-container .balance-label, .balance-title, @@ -588,7 +819,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { letter-spacing: 0.02em; text-transform: none; } - + .balance-item .balance-amount, .balance-item-container .balance-value, .balance-amount, @@ -602,39 +833,39 @@ pub fn layout(title: &str, content: Markup) -> Markup { white-space: nowrap; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; } - + .balance-item .info-label + .info-value, .balance-item .label + .amount, .balance-item-container .info-label + .info-value, .balance-item-container .label + .amount { margin-top: 6px; } - + .alert { border: 1px solid hsl(var(--border)); border-radius: var(--radius); padding: 1rem; margin-bottom: 1rem; } - + .alert-success { border-color: hsl(142.1 76.2% 36.3%); background-color: hsl(142.1 70.6% 45.3% / 0.1); color: hsl(142.1 76.2% 36.3%); } - + .alert-destructive { border-color: hsl(var(--destructive)); background-color: hsl(var(--destructive) / 0.1); color: hsl(var(--destructive)); } - + .alert-warning { border-color: hsl(32.6 75.4% 55.1%); background-color: hsl(32.6 75.4% 55.1% / 0.1); color: hsl(32.6 75.4% 55.1%); } - + /* Legacy classes for backward compatibility */ .success { border-color: hsl(142.1 76.2% 36.3%); @@ -645,7 +876,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { padding: 1rem; margin-bottom: 1rem; } - + .error { border-color: hsl(var(--destructive)); background-color: hsl(var(--destructive) / 0.1); @@ -655,7 +886,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { padding: 1rem; margin-bottom: 1rem; } - + .badge { display: inline-flex; align-items: center; @@ -667,34 +898,34 @@ pub fn layout(title: &str, content: Markup) -> Markup { transition: all 150ms ease-in-out; border: 1px solid transparent; } - + .badge-default { background-color: hsl(var(--primary)); color: hsl(var(--primary-foreground)); } - + .badge-secondary { background-color: hsl(var(--secondary)); color: hsl(var(--secondary-foreground)); } - + .badge-success { background-color: hsl(142.1 70.6% 45.3%); color: hsl(355.7 78% 98.4%); } - + .badge-destructive { background-color: hsl(var(--destructive)); color: hsl(var(--destructive-foreground)); } - + .badge-outline { background-color: transparent; color: hsl(var(--foreground)); border: 1px solid hsl(var(--border)); } - - /* Legacy status classes */ + + /* Status badge classes - consistent with payment type badges */ .status-badge { display: inline-flex; align-items: center; @@ -704,55 +935,127 @@ pub fn layout(title: &str, content: Markup) -> Markup { font-weight: 500; line-height: 1; } - + .status-active { - background-color: hsl(142.1 70.6% 45.3%); - color: hsl(355.7 78% 98.4%); + background-color: hsl(142.1 70.6% 45.3% / 0.1); + color: hsl(142.1 70.6% 45.3%); + border: 1px solid hsl(142.1 70.6% 45.3% / 0.2); } - + .status-inactive { - background-color: hsl(var(--destructive)); - color: hsl(var(--destructive-foreground)); + background-color: hsl(0 84.2% 60.2% / 0.1); + color: hsl(0 84.2% 60.2%); + border: 1px solid hsl(0 84.2% 60.2% / 0.2); } - - .channel-item { + + .status-pending { + background-color: hsl(215.4 16.3% 46.9% / 0.1); + color: hsl(215.4 16.3% 46.9%); + border: 1px solid hsl(215.4 16.3% 46.9% / 0.2); + } + + .channel-box { background-color: hsl(var(--card)); border: 1px solid hsl(var(--border)); border-radius: var(--radius); padding: 1.5rem; margin-bottom: 1.5rem; } - - .channel-header { - display: flex; - justify-content: space-between; - align-items: center; + + .section-header { + font-size: 1.25rem; + font-weight: 700; + color: hsl(var(--foreground)); + margin-bottom: 1.5rem; + line-height: 1.2; + } + + .channel-alias { + font-size: 1.25rem; + font-weight: 600; + color: hsl(var(--foreground)); margin-bottom: 1rem; + line-height: 1.2; + } + + .channel-details { + margin-bottom: 1.5rem; + } + + .detail-row { + display: flex; + align-items: baseline; + margin-bottom: 0.75rem; gap: 1rem; } - - .channel-id { + + .detail-row:last-child { + margin-bottom: 0; + } + + .detail-label { + font-weight: 500; + color: hsl(var(--muted-foreground)); + font-size: 0.875rem; + min-width: 120px; + flex-shrink: 0; + } + + .detail-value { + color: hsl(var(--foreground)); font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; font-size: 0.875rem; - color: hsl(var(--muted-foreground)); word-break: break-all; flex: 1; min-width: 0; } - + + .detail-value-amount { + color: hsl(var(--foreground)); + font-size: 0.875rem; + word-break: break-all; + flex: 1; + min-width: 0; + } + + .channel-actions { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 1rem; + gap: 1rem; + } + + @media (max-width: 640px) { + .channel-actions { + flex-direction: column; + align-items: stretch; + } + + .detail-row { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } + + .detail-label { + min-width: auto; + } + } + .balance-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 1rem; margin-top: 1rem; } - + @media (max-width: 640px) { .balance-info { grid-template-columns: 1fr; } } - + .balance-item { text-align: center; padding: 1rem; @@ -760,7 +1063,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { border-radius: calc(var(--radius) - 2px); border: 1px solid hsl(var(--border)); } - + .balance-amount { font-weight: 600; font-size: 1.125rem; @@ -768,9 +1071,9 @@ pub fn layout(title: &str, content: Markup) -> Markup { color: hsl(var(--foreground)); line-height: 1.2; } - - + + .payment-item { background-color: hsl(var(--card)); border: 1px solid hsl(var(--border)); @@ -778,7 +1081,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { padding: 1.5rem; margin-bottom: 1.5rem; } - + .payment-header { display: flex; justify-content: space-between; @@ -786,7 +1089,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { margin-bottom: 1rem; gap: 1rem; } - + @media (max-width: 640px) { .payment-header { flex-direction: column; @@ -794,7 +1097,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { gap: 0.75rem; } } - + .payment-direction { display: flex; align-items: center; @@ -804,19 +1107,19 @@ pub fn layout(title: &str, content: Markup) -> Markup { flex: 1; min-width: 0; } - + .direction-icon { font-size: 1.125rem; font-weight: bold; color: hsl(var(--muted-foreground)); } - + .payment-details { display: flex; flex-direction: column; gap: 0.75rem; } - + .payment-amount { font-size: 1.25rem; font-weight: 600; @@ -824,14 +1127,14 @@ pub fn layout(title: &str, content: Markup) -> Markup { color: hsl(var(--foreground)); line-height: 1.2; } - + .payment-info { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } - + @media (max-width: 640px) { .payment-info { flex-direction: column; @@ -839,14 +1142,14 @@ pub fn layout(title: &str, content: Markup) -> Markup { gap: 0.25rem; } } - + .payment-label { font-weight: 500; color: hsl(var(--muted-foreground)); font-size: 0.875rem; flex-shrink: 0; } - + .payment-value { color: hsl(var(--foreground)); font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; @@ -854,7 +1157,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { word-break: break-all; min-width: 0; } - + .payment-list-header { display: flex; justify-content: space-between; @@ -863,7 +1166,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); } - + @media (max-width: 640px) { .payment-list-header { flex-direction: column; @@ -871,14 +1174,14 @@ pub fn layout(title: &str, content: Markup) -> Markup { gap: 1rem; } } - + .payment-filter-tabs { display: flex; gap: 0.25rem; overflow-x: auto; -webkit-overflow-scrolling: touch; } - + .payment-filter-tab { display: inline-flex; align-items: center; @@ -895,19 +1198,43 @@ pub fn layout(title: &str, content: Markup) -> Markup { transition: all 150ms ease-in-out; height: 2.25rem; } - + .payment-filter-tab:hover { background-color: hsl(var(--accent)); color: hsl(var(--accent-foreground)); text-decoration: none; } - + .payment-filter-tab.active { background-color: hsl(var(--primary)); color: hsl(var(--primary-foreground)); border-color: hsl(var(--primary)); } - + + /* Dark mode specific styling for payment filter tabs */ + @media (prefers-color-scheme: dark) { + .payment-filter-tab { + background-color: rgba(255, 255, 255, 0.03) !important; + border-color: var(--text-muted) !important; + color: var(--text-muted) !important; + } + + .payment-filter-tab:hover { + background-color: rgba(255, 255, 255, 0.08) !important; + color: var(--text-secondary) !important; + } + + .payment-filter-tab.active { + background-color: rgba(255, 255, 255, 0.12) !important; + color: var(--text-primary) !important; + border-color: var(--text-secondary) !important; + } + + .payment-filter-tab.active:hover { + background-color: rgba(255, 255, 255, 0.15) !important; + } + } + .payment-type-badge { display: inline-flex; align-items: center; @@ -920,43 +1247,43 @@ pub fn layout(title: &str, content: Markup) -> Markup { text-transform: uppercase; letter-spacing: 0.05em; } - + .payment-type-bolt11 { background-color: hsl(217 91% 60% / 0.1); color: hsl(217 91% 60%); border: 1px solid hsl(217 91% 60% / 0.2); } - + .payment-type-bolt12 { background-color: hsl(262 83% 58% / 0.1); color: hsl(262 83% 58%); border: 1px solid hsl(262 83% 58% / 0.2); } - + .payment-type-onchain { background-color: hsl(32 95% 44% / 0.1); color: hsl(32 95% 44%); border: 1px solid hsl(32 95% 44% / 0.2); } - + .payment-type-spontaneous { background-color: hsl(142.1 70.6% 45.3% / 0.1); color: hsl(142.1 70.6% 45.3%); border: 1px solid hsl(142.1 70.6% 45.3% / 0.2); } - + .payment-type-bolt11-jit { background-color: hsl(199 89% 48% / 0.1); color: hsl(199 89% 48%); border: 1px solid hsl(199 89% 48% / 0.2); } - + .payment-type-unknown { background-color: hsl(var(--muted)); color: hsl(var(--muted-foreground)); border: 1px solid hsl(var(--border)); } - + /* Pagination */ .pagination-controls { display: flex; @@ -964,14 +1291,14 @@ pub fn layout(title: &str, content: Markup) -> Markup { align-items: center; margin: 2rem 0; } - + .pagination { display: flex; align-items: center; gap: 0.25rem; list-style: none; } - + .pagination-btn, .pagination-number { display: inline-flex; align-items: center; @@ -990,19 +1317,19 @@ pub fn layout(title: &str, content: Markup) -> Markup { min-width: 2.25rem; padding: 0 0.5rem; } - + .pagination-btn:hover, .pagination-number:hover { background-color: hsl(var(--accent)); color: hsl(var(--accent-foreground)); text-decoration: none; } - + .pagination-number.active { background-color: hsl(var(--primary)); color: hsl(var(--primary-foreground)); border-color: hsl(var(--primary)); } - + .pagination-btn.disabled { background-color: hsl(var(--muted)); color: hsl(var(--muted-foreground)); @@ -1010,7 +1337,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { opacity: 0.5; pointer-events: none; } - + .pagination-ellipsis { display: flex; align-items: center; @@ -1020,31 +1347,31 @@ pub fn layout(title: &str, content: Markup) -> Markup { color: hsl(var(--muted-foreground)); font-size: 0.875rem; } - + /* Responsive adjustments */ @media (max-width: 640px) { .container { padding: 0 1rem; } - + header { padding: 1rem 0; margin-bottom: 1rem; } - + h1 { font-size: 1.5rem; } - + nav ul { flex-wrap: wrap; } - + .card { padding: 1rem; margin-bottom: 1rem; } - + .info-item { flex-direction: column; align-items: flex-start; @@ -1052,43 +1379,43 @@ pub fn layout(title: &str, content: Markup) -> Markup { padding: 1rem 0; min-height: auto; } - + .info-value, .truncate-value { text-align: left; max-width: 100%; } - + .copy-button { margin-left: 0; margin-top: 0.25rem; align-self: flex-start; } - + .balance-amount-value { font-size: 1.25rem; } - + .pagination { flex-wrap: wrap; justify-content: center; gap: 0.125rem; } - + .pagination-btn, .pagination-number { height: 2rem; min-width: 2rem; font-size: 0.75rem; } } - + /* Node Information Section Styling */ .node-info-section { display: flex; gap: 1.5rem; margin-bottom: 1.5rem; - align-items: flex-start; + align-items: stretch; } - + .node-info-main-container { flex: 1; display: flex; @@ -1099,15 +1426,16 @@ pub fn layout(title: &str, content: Markup) -> Markup { border-radius: var(--radius); padding: 1.5rem; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + height: 100%; } - + .node-info-left { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; } - + .node-avatar { flex-shrink: 0; background-color: hsl(var(--muted) / 0.3); @@ -1120,7 +1448,7 @@ pub fn layout(title: &str, content: Markup) -> Markup { width: 80px; height: 80px; } - + .avatar-image { width: 48px; height: 48px; @@ -1128,12 +1456,12 @@ pub fn layout(title: &str, content: Markup) -> Markup { object-fit: cover; display: block; } - + .node-details { flex: 1; min-width: 0; } - + .node-name { font-size: var(--fs-title); font-weight: var(--fw-semibold); @@ -1144,14 +1472,14 @@ pub fn layout(title: &str, content: Markup) -> Markup { overflow-wrap: break-word; hyphens: auto; } - + .node-address { font-size: 0.875rem; color: var(--fg-muted); margin: 0; line-height: var(--lh-normal); } - + .node-content-box { background-color: hsl(var(--muted) / 0.3); border: 1px solid hsl(var(--border)); @@ -1164,106 +1492,119 @@ pub fn layout(title: &str, content: Markup) -> Markup { color: hsl(var(--muted-foreground)); overflow: hidden; } - + .node-metrics { flex-shrink: 0; width: 280px; display: flex; flex-direction: column; + align-self: stretch; } - + .node-metrics .card { margin-bottom: 0; flex: 1; display: flex; flex-direction: column; + align-self: stretch; } - + .node-metrics .metrics-container { flex-direction: column; margin: 1rem 0 0 0; flex: 1; + display: flex; + justify-content: flex-start; + gap: 1rem; + align-items: stretch; } - + .node-metrics .metric-card { min-width: auto; + padding: 1rem; + height: fit-content; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; } - + /* Mobile responsive design for node info */ @media (max-width: 768px) { .node-info-section { flex-direction: column; gap: 1rem; } - + .node-info-left { flex-direction: column; align-items: flex-start; text-align: center; gap: 0.75rem; } - + .node-avatar { align-self: center; } - + .node-details { text-align: center; width: 100%; } - + .node-content-box { min-height: 150px; padding: 1rem; } - + .node-metrics { width: 100%; } - + .node-metrics .metrics-container { flex-direction: row; flex-wrap: wrap; } - + .node-metrics .metric-card { flex: 1; min-width: 120px; } } - + @media (max-width: 480px) { .node-info-left { gap: 0.5rem; } - + .node-avatar { width: 64px; height: 64px; padding: 0.5rem; } - + .avatar-image { width: 40px; height: 40px; } - + .node-name { font-size: 1rem; word-wrap: break-word; overflow-wrap: break-word; hyphens: auto; } - + .node-address { font-size: 0.8125rem; } - + .node-content-box { min-height: 120px; padding: 0.75rem; } - + .node-metrics .metrics-container { flex-direction: column; gap: 0.75rem; @@ -1275,12 +1616,12 @@ pub fn layout(title: &str, content: Markup) -> Markup { :root { --fs-value: 1.45rem; } - + .node-name { font-size: 0.875rem; } } - + @media (max-width: 480px) { .node-name { font-size: 0.8125rem; @@ -1300,19 +1641,70 @@ pub fn layout(title: &str, content: Markup) -> Markup { body { header { div class="container" { - h1 { "CDK LDK Node" } - p class="subtitle" { "Lightning Network Node Management" } + div class="header-content" { + div class="header-left" { + div class="header-avatar" { + img src="/static/images/nut.png" alt="CDK LDK Node Icon" class="header-avatar-image"; + } + div class="node-info" { + div class="node-status" { + span class="status-indicator status-running" {} + span class="status-text" { "Running" } + } + h1 class="node-title" { "CDK LDK Node" } + span class="node-subtitle" { "Cashu Mint & Lightning Network Node Management" } + } + } + div class="header-right" { + // Right side content can be added here later if needed + } + } } } nav { div class="container" { ul { - li { a href="/" { "Dashboard" } } - li { a href="/balance" { "Lightning" } } - li { a href="/onchain" { "On-chain" } } - li { a href="/invoices" { "Invoices" } } - li { a href="/payments" { "All Payments" } } + li { + a href="/" { + svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 0.5rem;" { + path d="M15.6 2.7a10 10 0 1 0 5.7 5.7" {} + circle cx="12" cy="12" r="2" {} + path d="M13.4 10.6 19 5" {} + } + "Dashboard" + } + } + li { + a href="/balance" { + svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 0.5rem;" { + path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z" {} + } + "Lightning" + } + } + li { + a href="/onchain" { + svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 0.5rem;" { + path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" {} + path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" {} + } + "On-chain" + } + } + li { + a href="/payments" { + svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 0.5rem;" { + path d="M12 18H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5" {} + path d="m16 19 3 3 3-3" {} + path d="M18 12h.01" {} + path d="M19 16v6" {} + path d="M6 12h.01" {} + circle cx="12" cy="12" r="2" {} + } + "All Payments" + } + } } } } @@ -1320,6 +1712,8 @@ pub fn layout(title: &str, content: Markup) -> Markup { main class="container" { (content) } + + } } } diff --git a/crates/cdk-ldk-node/src/web/templates/payments.rs b/crates/cdk-ldk-node/src/web/templates/payments.rs index 84455bd4..479d0764 100644 --- a/crates/cdk-ldk-node/src/web/templates/payments.rs +++ b/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", }; diff --git a/crates/cdk-ldk-node/static/images/bg-dark.jpg b/crates/cdk-ldk-node/static/images/bg-dark.jpg index 8cd9b7c2..d96f3453 100644 Binary files a/crates/cdk-ldk-node/static/images/bg-dark.jpg and b/crates/cdk-ldk-node/static/images/bg-dark.jpg differ diff --git a/crates/cdk-lnbits/src/lib.rs b/crates/cdk-lnbits/src/lib.rs index bda71b2a..2f5f84bb 100644 --- a/crates/cdk-lnbits/src/lib.rs +++ b/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 + Send>>, Self::Err> { + ) -> Result + 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))) } } }, diff --git a/crates/cdk-lnd/src/lib.rs b/crates/cdk-lnd/src/lib.rs index 0dfe7ca0..fc6e49df 100644 --- a/crates/cdk-lnd/src/lib.rs +++ b/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 + Send>>, Self::Err> { + ) -> Result + 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 diff --git a/crates/cdk-payment-processor/src/proto/client.rs b/crates/cdk-payment-processor/src/proto/client.rs index cad434d1..d88876af 100644 --- a/crates/cdk-payment-processor/src/proto/client.rs +++ b/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 + Send>>, Self::Err> { + ) -> Result + 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 diff --git a/crates/cdk-payment-processor/src/proto/server.rs b/crates/cdk-payment-processor/src/proto/server.rs index 9085c3fd..81231a8e 100644 --- a/crates/cdk-payment-processor/src/proto/server.rs +++ b/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; + } + } } } } diff --git a/crates/cdk-postgres/Cargo.toml b/crates/cdk-postgres/Cargo.toml index cadcf2bd..474bf9cf 100644 --- a/crates/cdk-postgres/Cargo.toml +++ b/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 diff --git a/crates/cdk-postgres/src/lib.rs b/crates/cdk-postgres/src/lib.rs index 408e60ec..579b966f 100644 --- a/crates/cdk-postgres/src/lib.rs +++ b/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, } } } diff --git a/crates/cdk-sql-common/Cargo.toml b/crates/cdk-sql-common/Cargo.toml index 2c54c957..7785ff4d 100644 --- a/crates/cdk-sql-common/Cargo.toml +++ b/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 diff --git a/crates/cdk-sql-common/src/mint/mod.rs b/crates/cdk-sql-common/src/mint/mod.rs index 02f28150..ec7ae447 100644 --- a/crates/cdk-sql-common/src/mint/mod.rs +++ b/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, + quote_id: Option, ) -> 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, + _quote_id: Option, ) -> 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, 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, Self::Err> { + async fn get_proof_ys_by_quote_id( + &self, + quote_id: &QuoteId, + ) -> Result, 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() diff --git a/crates/cdk-sql-common/src/wallet/migrations.rs b/crates/cdk-sql-common/src/wallet/migrations.rs index 20cf48a3..5838a020 100644 --- a/crates/cdk-sql-common/src/wallet/migrations.rs +++ b/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"#)), ]; diff --git a/crates/cdk-sql-common/src/wallet/migrations/postgres/20250831215438_melt_quote_method.sql b/crates/cdk-sql-common/src/wallet/migrations/postgres/20250831215438_melt_quote_method.sql new file mode 100644 index 00000000..effd9d25 --- /dev/null +++ b/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'; diff --git a/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250831215438_melt_quote_method.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250831215438_melt_quote_method.sql new file mode 100644 index 00000000..effd9d25 --- /dev/null +++ b/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'; diff --git a/crates/cdk-sql-common/src/wallet/mod.rs b/crates/cdk-sql-common/src/wallet/mod.rs index a0008275..b4bda761 100644 --- a/crates/cdk-sql-common/src/wallet/mod.rs +++ b/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) -> Result { 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) -> Result { state: column_as_string!(state, MeltQuoteState::from_str), expiry: column_as_number!(expiry), payment_preimage: column_as_nullable_string!(payment_preimage), + payment_method, }) } diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index 77a5de43..dec29d19 100644 --- a/crates/cdk/Cargo.toml +++ b/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]] diff --git a/crates/cdk/examples/mint-token-bolt12-with-custom-http.rs b/crates/cdk/examples/mint-token-bolt12-with-custom-http.rs new file mode 100644 index 00000000..31d16b30 --- /dev/null +++ b/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(&self, url: Url, _auth: Option) -> Result + 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( + &self, + url: Url, + _auth_token: Option, + payload: &P, + ) -> Result + 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; + +#[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(()) +} diff --git a/crates/cdk/src/mint/mod.rs b/crates/cdk/src/mint/mod.rs index 1c4ffb64..ec89e81b 100644 --- a/crates/cdk/src/mint/mod.rs +++ b/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); + } + } } } } diff --git a/crates/cdk/src/pub_sub.rs b/crates/cdk/src/pub_sub.rs index ceec2ed3..c5f98a33 100644 --- a/crates/cdk/src/pub_sub.rs +++ b/crates/cdk/src/pub_sub.rs @@ -41,10 +41,10 @@ pub struct Manager where T: Indexable + Clone + Send + Sync + 'static, I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static, - F: OnNewSubscription + 'static, + F: OnNewSubscription + Send + Sync + 'static, { indexes: IndexTree, - on_new_subscription: Option, + on_new_subscription: Option>, unsubscription_sender: mpsc::Sender<(SubId, Vec>)>, active_subscriptions: Arc, background_subscription_remover: Option>, @@ -54,7 +54,7 @@ impl Default for Manager where T: Indexable + Clone + Send + Sync + 'static, I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static, - F: OnNewSubscription + 'static, + F: OnNewSubscription + Send + Sync + 'static, { fn default() -> Self { let (sender, receiver) = mpsc::channel(DEFAULT_REMOVE_SIZE); @@ -79,11 +79,11 @@ impl From for Manager where T: Indexable + Clone + Send + Sync + 'static, I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static, - F: OnNewSubscription + 'static, + F: OnNewSubscription + 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 Manager where T: Indexable + Clone + Send + Sync + 'static, I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static, - F: OnNewSubscription + 'static, + F: OnNewSubscription + Send + Sync + 'static, { #[inline] /// Broadcast an event to all listeners @@ -143,32 +143,45 @@ where indexes: Vec>, ) -> ActiveSubscription { 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::>()) - .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::>(), + ) + .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 Drop for Manager where T: Indexable + Clone + Send + Sync + 'static, I: Clone + Debug + PartialOrd + Ord + Send + Sync + 'static, - F: OnNewSubscription + 'static, + F: OnNewSubscription + Send + Sync + 'static, { fn drop(&mut self) { if let Some(handler) = self.background_subscription_remover.take() { diff --git a/crates/cdk/src/wallet/builder.rs b/crates/cdk/src/wallet/builder.rs index 3caf2b27..2b2583d0 100644 --- a/crates/cdk/src/wallet/builder.rs +++ b/crates/cdk/src/wallet/builder.rs @@ -26,6 +26,7 @@ pub struct WalletBuilder { #[cfg(feature = "auth")] auth_wallet: Option, seed: Option<[u8; 64]>, + use_http_subscription: bool, client: Option>, } @@ -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), }) } } diff --git a/crates/cdk/src/wallet/issue/issue_bolt12.rs b/crates/cdk/src/wallet/issue/issue_bolt12.rs index 7df88942..085cbc58 100644 --- a/crates/cdk/src/wallet/issue/issue_bolt12.rs +++ b/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; diff --git a/crates/cdk/src/wallet/melt/melt_bolt11.rs b/crates/cdk/src/wallet/melt/melt_bolt11.rs index 9f82c1e8..63ef4de6 100644 --- a/crates/cdk/src/wallet/melt/melt_bolt11.rs +++ b/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, diff --git a/crates/cdk/src/wallet/melt/melt_bolt12.rs b/crates/cdk/src/wallet/melt/melt_bolt12.rs index 95707b80..9728e79d 100644 --- a/crates/cdk/src/wallet/melt/melt_bolt12.rs +++ b/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?; diff --git a/crates/cdk/src/wallet/mint_connector/http_client.rs b/crates/cdk/src/wallet/mint_connector/http_client.rs index 577d012f..93cb752c 100644 --- a/crates/cdk/src/wallet/mint_connector/http_client.rs +++ b/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( - &self, - url: U, - auth: Option, - ) -> Result { - 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::(&response).map_err(|err| { - tracing::warn!("Http Response error: {}", err); - match ErrorResponse::from_json(&response) { - Ok(ok) => >::into(ok), - Err(err) => err.into(), - } - }) - } - - async fn http_post( - &self, - url: U, - auth_token: Option, - payload: &P, - ) -> Result { - 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::(&response).map_err(|err| { - tracing::warn!("Http Response error: {}", err); - match ErrorResponse::from_json(&response) { - Ok(ok) => >::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 +where + T: Transport + Send + Sync + 'static, +{ + transport: Arc, mint_url: MintUrl, cache_support: Arc>, #[cfg(feature = "auth")] auth_wallet: Arc>>, } -impl HttpClient { +impl HttpClient +where + T: Transport + Send + Sync + 'static, +{ /// Create new [`HttpClient`] #[cfg(feature = "auth")] pub fn new(mint_url: MintUrl, auth_wallet: Option) -> 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 { - 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 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 MintConnector for HttpClient +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, Error> { let url = self.mint_url.join_paths(&["v1", "keys"])?; Ok(self - .core - .http_get::<_, KeysResponse>(url, None) + .transport + .http_get::(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::(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 { 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 { 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 +where + T: Transport + Send + Sync + 'static, +{ + transport: Arc, mint_url: MintUrl, cat: Arc>, } #[cfg(feature = "auth")] -impl AuthHttpClient { +impl AuthHttpClient +where + T: Transport + Send + Sync + 'static, +{ /// Create new [`AuthHttpClient`] pub fn new(mint_url: MintUrl, cat: Option) -> 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 AuthMintConnector for AuthHttpClient +where + T: Transport + Send + Sync + 'static, +{ async fn get_auth_token(&self) -> Result { 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 { 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::(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::(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 { 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 } diff --git a/crates/cdk/src/wallet/mint_connector/mod.rs b/crates/cdk/src/wallet/mint_connector/mod.rs index eb88944a..8675d294 100644 --- a/crates/cdk/src/wallet/mint_connector/mod.rs +++ b/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; +/// Http Client with async transport +pub type HttpClient = http_client::HttpClient; /// Interface that connects a wallet to a mint. Typically represents an [HttpClient]. #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] diff --git a/crates/cdk/src/wallet/mint_connector/transport.rs b/crates/cdk/src/wallet/mint_connector/transport.rs new file mode 100644 index 00000000..abd3b55e --- /dev/null +++ b/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(&self, url: Url, auth: Option) -> Result + where + R: DeserializeOwned; + + /// HTTP Post request + async fn http_post( + &self, + url: Url, + auth_token: Option, + payload: &P, + ) -> Result + 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(&self, url: Url, auth: Option) -> Result + 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::(&response).map_err(|err| { + tracing::warn!("Http Response error: {}", err); + match ErrorResponse::from_json(&response) { + Ok(ok) => >::into(ok), + Err(err) => err.into(), + } + }) + } + + async fn http_post( + &self, + url: Url, + auth_token: Option, + payload: &P, + ) -> Result + 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::(&response).map_err(|err| { + tracing::warn!("Http Response error: {}", err); + match ErrorResponse::from_json(&response) { + Ok(ok) => >::into(ok), + Err(err) => err.into(), + } + }) + } +} diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index 1bb41c32..3209be7d 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/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; diff --git a/crates/cdk/src/wallet/subscription/http.rs b/crates/cdk/src/wallet/subscription/http.rs index fa8d69a5..22290e91 100644 --- a/crates/cdk/src/wallet/subscription/http.rs +++ b/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), 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>( 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; diff --git a/crates/cdk/src/wallet/subscription/mod.rs b/crates/cdk/src/wallet/subscription/mod.rs index 6acaed44..a2c4afc3 100644 --- a/crates/cdk/src/wallet/subscription/mod.rs +++ b/crates/cdk/src/wallet/subscription/mod.rs @@ -48,14 +48,16 @@ type WsSubscriptionBody = (mpsc::Sender, Params); pub struct SubscriptionManager { all_connections: Arc>>, http_client: Arc, + prefer_http: bool, } impl SubscriptionManager { /// Create a new subscription manager - pub fn new(http_client: Arc) -> Self { + pub fn new(http_client: Arc, 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, diff --git a/crates/cdk/src/wallet/subscription/ws.rs b/crates/cdk/src/wallet/subscription/ws.rs index 4eb78bad..1e3c834e 100644 --- a/crates/cdk/src/wallet/subscription/ws.rs +++ b/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>( - initial_state: S, - http_client: Arc, - subscriptions: Arc>>, - new_subscription_recv: mpsc::Receiver, - on_drop: mpsc::Receiver, - wallet: Arc, -) { - http_main( - initial_state, - http_client, - subscriptions, - new_subscription_recv, - on_drop, - wallet, - ) - .await -} - #[inline] pub async fn ws_main( http_client: Arc, @@ -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; } } } diff --git a/flake.lock b/flake.lock index 0b3c7981..bb202710 100644 --- a/flake.lock +++ b/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": { diff --git a/flake.nix b/flake.nix index cbd3b0bc..fbcc431a 100644 --- a/flake.nix +++ b/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 [ diff --git a/justfile b/justfile index 9b2520f1..adec0bd8 100644 --- a/justfile +++ b/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"