From cb29e74e3930326cc13b6e2c0a2b647ba8fb9930 Mon Sep 17 00:00:00 2001 From: asmo Date: Tue, 26 Aug 2025 21:27:25 +0200 Subject: [PATCH 01/14] feat: add TLS support for PostgreSQL connections with configurable SSL modes --- crates/cdk-postgres/Cargo.toml | 1 + crates/cdk-postgres/src/lib.rs | 48 +++++++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/cdk-postgres/Cargo.toml b/crates/cdk-postgres/Cargo.toml index cadcf2bd..5b5b4af2 100644 --- a/crates/cdk-postgres/Cargo.toml +++ b/crates/cdk-postgres/Cargo.toml @@ -32,4 +32,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..e32a0f35 100644 --- a/crates/cdk-postgres/src/lib.rs +++ b/crates/cdk-postgres/src/lib.rs @@ -10,6 +10,9 @@ 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; +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 +28,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 +69,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, } } } From 6454a33509c1725771812d4ab0f3579f57298b1b Mon Sep 17 00:00:00 2001 From: asmo Date: Wed, 27 Aug 2025 15:15:16 +0200 Subject: [PATCH 02/14] chore: remove unused `native_tls` import from `cdk-postgres` --- crates/cdk-postgres/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/cdk-postgres/src/lib.rs b/crates/cdk-postgres/src/lib.rs index e32a0f35..579b966f 100644 --- a/crates/cdk-postgres/src/lib.rs +++ b/crates/cdk-postgres/src/lib.rs @@ -10,7 +10,6 @@ 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; use native_tls::TlsConnector; use postgres_native_tls::MakeTlsConnector; use tokio::sync::{Mutex, Notify}; From 7995a93943305c3fee0acda1f24f598de7df7289 Mon Sep 17 00:00:00 2001 From: lollerfirst <43107113+lollerfirst@users.noreply.github.com> Date: Fri, 29 Aug 2025 11:37:01 +0200 Subject: [PATCH 03/14] patch sha256 (#1013) --- crates/cashu/src/nuts/nut13.rs | 46 ++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 19 deletions(-) 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); } } From 7a22f851853d0aa484d19b3030fdf32c0785ca6b Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Fri, 29 Aug 2025 10:37:32 +0100 Subject: [PATCH 04/14] chore: remove readme postgres (#1009) * chore: remove readme postgres --- crates/cdk-postgres/Cargo.toml | 1 - justfile | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/cdk-postgres/Cargo.toml b/crates/cdk-postgres/Cargo.toml index cadcf2bd..ed87d57f 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] 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" From 2131f89068dac2a40bb49b0bff5a9727821959b8 Mon Sep 17 00:00:00 2001 From: C Date: Sat, 30 Aug 2025 04:54:48 -0300 Subject: [PATCH 05/14] Abstract the HTTP Transport (#1012) * Abstract the HTTP Transport This PR allows replacing the HTTP transport layer with another library, allowing wallet ffi to provide a better-suited HTTP library that would be used instead of Reqwest. --- .../cashu/src/nuts/nut18/payment_request.rs | 2 +- crates/cdk-integration-tests/tests/bolt12.rs | 10 +- .../cdk-integration-tests/tests/fake_auth.rs | 2 +- .../tests/happy_path_mint_wallet.rs | 10 +- .../cdk-integration-tests/tests/ldk_node.rs | 4 +- crates/cdk-integration-tests/tests/regtest.rs | 8 +- .../cdk-integration-tests/tests/test_fees.rs | 4 +- crates/cdk/Cargo.toml | 6 + .../mint-token-bolt12-with-custom-http.rs | 161 +++++++++++++ .../src/wallet/mint_connector/http_client.rs | 216 +++++------------- crates/cdk/src/wallet/mint_connector/mod.rs | 9 +- .../src/wallet/mint_connector/transport.rs | 182 +++++++++++++++ crates/cdk/src/wallet/mod.rs | 4 + 13 files changed, 439 insertions(+), 179 deletions(-) create mode 100644 crates/cdk/examples/mint-token-bolt12-with-custom-http.rs create mode 100644 crates/cdk/src/wallet/mint_connector/transport.rs 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/cdk-integration-tests/tests/bolt12.rs b/crates/cdk-integration-tests/tests/bolt12.rs index 44873f74..514a169f 100644 --- a/crates/cdk-integration-tests/tests/bolt12.rs +++ b/crates/cdk-integration-tests/tests/bolt12.rs @@ -120,7 +120,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 +136,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 +187,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 +206,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 +283,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/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/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; From df8b78043e58e48af501c83eb81ec95415ac5116 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sun, 31 Aug 2025 12:00:04 +0100 Subject: [PATCH 06/14] chore: update flake (#1018) --- flake.lock | 18 +++++++++--------- flake.nix | 6 +++++- 2 files changed, 14 insertions(+), 10 deletions(-) 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 [ From 7a71a37eab51d2ee8d6bee6d6637050f7851ec03 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sun, 31 Aug 2025 17:26:50 +0100 Subject: [PATCH 07/14] refactor(payment): replace wait_any_incoming_payment with event-based system (#1019) Rename wait_any_incoming_payment to wait_payment_event and change return type from WaitPaymentResponse stream to Event stream. This introduces a new Event enum that wraps payment responses, making the system more extensible for future event types. - Add Event enum with PaymentReceived variant - Update MintPayment trait method signature - Refactor all payment backend implementations (LND, CLN, LNBits, fake wallet) - Update mint and payment processor to handle new event stream forma --- CHANGELOG.md | 8 ++++++ crates/cdk-cln/src/lib.rs | 9 ++++--- crates/cdk-common/src/payment.rs | 11 ++++++-- crates/cdk-fake-wallet/src/lib.rs | 19 ++++++++------ crates/cdk-ldk-node/src/lib.rs | 8 +++--- crates/cdk-lnbits/src/lib.rs | 8 +++--- crates/cdk-lnd/src/lib.rs | 9 ++++--- .../cdk-payment-processor/src/proto/client.rs | 8 +++--- .../cdk-payment-processor/src/proto/server.rs | 26 +++++++++++-------- crates/cdk/src/mint/mod.rs | 20 ++++++++------ 10 files changed, 78 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ce7a94..11809dcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ ## [Unreleased] +### Added +- cdk-common: New `Event` enum for payment event handling with `PaymentReceived` variant ([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]). + ## [0.12.0](https://github.com/cashubtc/cdk/releases/tag/v0.12.0) ### Summary 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/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-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-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-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/src/mint/mod.rs b/crates/cdk/src/mint/mod.rs index 0c08e7d3..a4b5c1d4 100644 --- a/crates/cdk/src/mint/mod.rs +++ b/crates/cdk/src/mint/mod.rs @@ -528,16 +528,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); + } + } } } } From 7246ea2e1032989fe9b1f011414ee7645738257a Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sun, 31 Aug 2025 23:05:24 +0100 Subject: [PATCH 08/14] fix: bolt12 is nut25 (#1020) --- CHANGELOG.md | 2 ++ README.md | 2 ++ crates/cashu/src/nuts/mod.rs | 4 ++-- crates/cashu/src/nuts/{nut24.rs => nut25.rs} | 0 crates/cdk/src/wallet/issue/issue_bolt12.rs | 2 +- 5 files changed, 7 insertions(+), 3 deletions(-) rename crates/cashu/src/nuts/{nut24.rs => nut25.rs} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11809dcc..08c7d68b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ - 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]). ## [0.12.0](https://github.com/cashubtc/cdk/releases/tag/v0.12.0) 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/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/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/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; From 60672427935c2614d89893041ef149e9737af2b6 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Mon, 1 Sep 2025 08:39:51 +0100 Subject: [PATCH 09/14] fix: cdk melt quote track payment method (#1021) Add payment_method field to MeltQuote struct to track whether quotes use BOLT11 or BOLT12 payment methods. Include database migrations for both SQLite and PostgreSQL to support the new field. Update melt operations to set and use the payment method for proper routing between BOLT11 and BOLT12 endpoints. --- CHANGELOG.md | 5 +++++ crates/cdk-common/src/wallet.rs | 3 +++ .../cdk-sql-common/src/wallet/migrations.rs | 2 ++ .../20250831215438_melt_quote_method.sql | 1 + .../20250831215438_melt_quote_method.sql | 1 + crates/cdk-sql-common/src/wallet/mod.rs | 21 +++++++++++++------ crates/cdk/src/wallet/melt/melt_bolt11.rs | 10 ++++++++- crates/cdk/src/wallet/melt/melt_bolt12.rs | 2 ++ 8 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 crates/cdk-sql-common/src/wallet/migrations/postgres/20250831215438_melt_quote_method.sql create mode 100644 crates/cdk-sql-common/src/wallet/migrations/sqlite/20250831215438_melt_quote_method.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 08c7d68b..738b33c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### 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]). @@ -16,6 +18,9 @@ - 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/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-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..56d97b72 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 "#, @@ -579,16 +580,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 +601,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 +621,8 @@ ON CONFLICT(id) DO UPDATE SET fee_reserve, state, expiry, - payment_preimage + payment_preimage, + payment_method FROM melt_quote WHERE @@ -1124,13 +1128,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 +1148,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/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?; From 62f085b3504246aec9f1d8c85f00c4dfd2ef31ac Mon Sep 17 00:00:00 2001 From: C Date: Mon, 1 Sep 2025 16:24:50 -0300 Subject: [PATCH 10/14] Fix missed events race when creating subscriptions (#1023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, we fetched the initial state *before* registering the new subscription. Any events emitted after the DB read but before the subscription was installed were dropped—most visible under low-resource conditions (e.g., CI). Change: - Register the subscription first, then asynchronously fetch and send the initial state (spawned task). This eliminates the window where events could be missed. - Require `F: Send + Sync` and store `on_new_subscription` as `Arc` so it can be safely used from the spawned task. Result: - No gap between “subscribe” and “start receiving,” avoiding lost events. - Initial state still delivered, now via a background task. --- crates/cdk/src/pub_sub.rs | 65 +++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 26 deletions(-) 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() { From 655a4b9e1e3cc896c387eec6fabc7b70494f6815 Mon Sep 17 00:00:00 2001 From: C Date: Tue, 2 Sep 2025 05:12:54 -0300 Subject: [PATCH 11/14] Add suport for Bolt12 notifications for HTTP subscription (#1007) * Add suport for Bolt12 notifications for HTTP subscription This commit adds support for Mint Bolt12 Notifications for HTTP when Mint does not support WebSocket or the wallet decides not to use it, and falls back to HTTP. This PR fixes #992 --- crates/cdk-integration-tests/tests/bolt12.rs | 22 +++++++----- crates/cdk/src/wallet/builder.rs | 17 ++++++++- crates/cdk/src/wallet/subscription/http.rs | 22 +++++++++++- crates/cdk/src/wallet/subscription/mod.rs | 10 +++++- crates/cdk/src/wallet/subscription/ws.rs | 36 ++++++-------------- 5 files changed, 69 insertions(+), 38 deletions(-) diff --git a/crates/cdk-integration-tests/tests/bolt12.rs b/crates/cdk-integration-tests/tests/bolt12.rs index 514a169f..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?; 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/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; } } } From dca48f4886c2271114686613ac2cc0873e43ad67 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Tue, 2 Sep 2025 09:14:39 +0100 Subject: [PATCH 12/14] fix: get all mint quotes (#1025) --- crates/cdk-sql-common/src/wallet/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/cdk-sql-common/src/wallet/mod.rs b/crates/cdk-sql-common/src/wallet/mod.rs index 56d97b72..b4bda761 100644 --- a/crates/cdk-sql-common/src/wallet/mod.rs +++ b/crates/cdk-sql-common/src/wallet/mod.rs @@ -552,6 +552,9 @@ ON CONFLICT(id) DO UPDATE SET state, expiry, secret_key + payment_method, + amount_issued, + amount_paid FROM mint_quote "#, From 734e62b04acb366e5740cf6844c80efe6570a1f2 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Tue, 2 Sep 2025 10:47:26 +0100 Subject: [PATCH 13/14] refactor: use quote id to string (#1026) --- crates/cashu/src/lib.rs | 107 +------------- crates/cashu/src/quote_id.rs | 100 +++++++++++++ crates/cdk-common/src/database/mint/mod.rs | 18 ++- crates/cdk-common/src/database/mint/test.rs | 6 +- crates/cdk-sql-common/Cargo.toml | 1 - crates/cdk-sql-common/src/mint/mod.rs | 152 ++++---------------- 6 files changed, 148 insertions(+), 236 deletions(-) create mode 100644 crates/cashu/src/quote_id.rs 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/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-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-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() From 39f256a648b63f34d6bd43c0b3204c362e6ef670 Mon Sep 17 00:00:00 2001 From: Erik <78821053+swedishfrenchpress@users.noreply.github.com> Date: Wed, 3 Sep 2025 16:46:49 +0200 Subject: [PATCH 14/14] cdk-ldk web ui updates (#1027) --- crates/cdk-ldk-node/Cargo.toml | 5 +- .../cdk-ldk-node/src/web/handlers/channels.rs | 90 +- .../src/web/handlers/lightning.rs | 157 ++-- .../cdk-ldk-node/src/web/handlers/onchain.rs | 75 +- .../cdk-ldk-node/src/web/templates/layout.rs | 846 +++++++++++++----- .../src/web/templates/payments.rs | 2 +- crates/cdk-ldk-node/static/images/bg-dark.jpg | Bin 47534 -> 43605 bytes 7 files changed, 840 insertions(+), 335 deletions(-) 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/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 8cd9b7c209a40418a16fa4889ec1fe4768d21dfb..d96f3453cb0c66eac3845a3aea6181befc7ffcca 100644 GIT binary patch literal 43605 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 Vz@Gx6Kv8o4&k;Z*dgqA2{{eTwr-c9j literal 47534 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 F{|Em`p$h;2